diff --git a/.vscode/settings.json b/.vscode/settings.json index 2f4956493..8bbb9edfc 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -81,5 +81,11 @@ "statusBarItem.remoteBackground": "#111827", "statusBarItem.remoteForeground": "#e7e7e7", "commandCenter.border": "#e7e7e799" - } + }, + "json.schemas": [ + { + "fileMatch": ["jetstream-web-extension/**/manifest.json"], + "url": "https://json.schemastore.org/chrome-manifest" + } + ] } diff --git a/apps/api/src/app/controllers/sf-metadata-tooling.controller.ts b/apps/api/src/app/controllers/sf-metadata-tooling.controller.ts index 422df9fe0..a05977a88 100644 --- a/apps/api/src/app/controllers/sf-metadata-tooling.controller.ts +++ b/apps/api/src/app/controllers/sf-metadata-tooling.controller.ts @@ -11,11 +11,11 @@ import { RetrievePackageFromExistingServerPackagesRequestSchema, RetrievePackageFromLisMetadataResultsRequestSchema, } from '@jetstream/api-types'; +import { buildPackageXml, getRetrieveRequestFromListMetadata, getRetrieveRequestFromManifest } from '@jetstream/salesforce-api'; import { RetrieveRequest } from '@jetstream/types'; import JSZip from 'jszip'; import { isString } from 'lodash'; import { z } from 'zod'; -import { buildPackageXml, getRetrieveRequestFromListMetadata, getRetrieveRequestFromManifest } from '../services/salesforce.service'; import { UserFacingError } from '../utils/error-handler'; import { sendJson } from '../utils/response.handlers'; import { createRoute } from '../utils/route.utils'; diff --git a/apps/jetstream-web-extension-e2e/.eslintrc.json b/apps/jetstream-web-extension-e2e/.eslintrc.json new file mode 100644 index 000000000..fbf2c975e --- /dev/null +++ b/apps/jetstream-web-extension-e2e/.eslintrc.json @@ -0,0 +1,22 @@ +{ + "extends": ["plugin:playwright/recommended", "../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["src/**/*.{ts,js,tsx,jsx}"], + "rules": {} + } + ] +} diff --git a/apps/jetstream-web-extension-e2e/playwright.config.ts b/apps/jetstream-web-extension-e2e/playwright.config.ts new file mode 100644 index 000000000..cdbff754d --- /dev/null +++ b/apps/jetstream-web-extension-e2e/playwright.config.ts @@ -0,0 +1,69 @@ +import { defineConfig, devices } from '@playwright/test'; +import { nxE2EPreset } from '@nx/playwright/preset'; + +import { workspaceRoot } from '@nx/devkit'; + +// For CI, you may want to set BASE_URL to the deployed application. +const baseURL = process.env['BASE_URL'] || 'http://localhost:4200'; + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// require('dotenv').config(); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + ...nxE2EPreset(__filename, { testDir: './src' }), + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + baseURL, + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + }, + /* Run your local dev server before starting the tests */ + webServer: { + command: 'yarn nx serve jetstream-web-extension', + url: 'http://localhost:4200', + reuseExistingServer: !process.env.CI, + cwd: workspaceRoot, + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, + + // Uncomment for mobile browsers support + /* { + name: 'Mobile Chrome', + use: { ...devices['Pixel 5'] }, + }, + { + name: 'Mobile Safari', + use: { ...devices['iPhone 12'] }, + }, */ + + // Uncomment for branded browsers + /* { + name: 'Microsoft Edge', + use: { ...devices['Desktop Edge'], channel: 'msedge' }, + }, + { + name: 'Google Chrome', + use: { ...devices['Desktop Chrome'], channel: 'chrome' }, + } */ + ], +}); diff --git a/apps/jetstream-web-extension-e2e/project.json b/apps/jetstream-web-extension-e2e/project.json new file mode 100644 index 000000000..3be422535 --- /dev/null +++ b/apps/jetstream-web-extension-e2e/project.json @@ -0,0 +1,16 @@ +{ + "name": "jetstream-web-extension-e2e", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "projectType": "application", + "sourceRoot": "apps/jetstream-web-extension-e2e/src", + "implicitDependencies": ["jetstream-web-extension"], + "targets": { + "e2e": { + "executor": "@nx/playwright:playwright", + "outputs": ["{workspaceRoot}/dist/.playwright/apps/jetstream-web-extension-e2e"], + "options": { + "config": "apps/jetstream-web-extension-e2e/playwright.config.ts" + } + } + } +} diff --git a/apps/jetstream-web-extension-e2e/src/example.spec.ts b/apps/jetstream-web-extension-e2e/src/example.spec.ts new file mode 100644 index 000000000..fa8f1f335 --- /dev/null +++ b/apps/jetstream-web-extension-e2e/src/example.spec.ts @@ -0,0 +1,8 @@ +import { test, expect } from '@playwright/test'; + +test('has title', async ({ page }) => { + await page.goto('/'); + + // Expect h1 to contain a substring. + expect(await page.locator('h1').innerText()).toContain('Welcome'); +}); diff --git a/apps/jetstream-web-extension-e2e/tsconfig.json b/apps/jetstream-web-extension-e2e/tsconfig.json new file mode 100644 index 000000000..114364a11 --- /dev/null +++ b/apps/jetstream-web-extension-e2e/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "allowJs": true, + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "sourceMap": false + }, + "include": [ + "**/*.ts", + "**/*.js", + "playwright.config.ts", + "src/**/*.spec.ts", + "src/**/*.spec.js", + "src/**/*.test.ts", + "src/**/*.test.js", + "src/**/*.d.ts" + ] +} diff --git a/apps/jetstream-web-extension/.babelrc b/apps/jetstream-web-extension/.babelrc new file mode 100644 index 000000000..fa0562e52 --- /dev/null +++ b/apps/jetstream-web-extension/.babelrc @@ -0,0 +1,12 @@ +{ + "presets": [ + [ + "@nx/react/babel", + { + "runtime": "automatic", + "importSource": "@emotion/react" + } + ] + ], + "plugins": ["@emotion/babel-plugin"] +} diff --git a/apps/jetstream-web-extension/.eslintrc.json b/apps/jetstream-web-extension/.eslintrc.json new file mode 100644 index 000000000..a74d35dfc --- /dev/null +++ b/apps/jetstream-web-extension/.eslintrc.json @@ -0,0 +1,25 @@ +{ + "extends": ["plugin:@nx/react", "../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": { + "react-hooks/exhaustive-deps": [ + "warn", + { + "additionalHooks": "(useRecoilCallback|useNonInitialEffect)" + } + ] + } + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/apps/jetstream-web-extension/environments/environment.prod.ts b/apps/jetstream-web-extension/environments/environment.prod.ts new file mode 100644 index 000000000..a69234e1b --- /dev/null +++ b/apps/jetstream-web-extension/environments/environment.prod.ts @@ -0,0 +1,12 @@ +export const environment = { + name: 'Jetstream', + production: true, + rollbarClientAccessToken: process.env.NX_PUBLIC_ROLLBAR_KEY, + amplitudeToken: process.env.NX_PUBLIC_AMPLITUDE_KEY, + authAudience: process.env.NX_PUBLIC_AUTH_AUDIENCE, + MODE: process.env.MODE, + BASE_URL: process.env.BASE_URL, + PROD: process.env.PROD, + DEV: process.env.DEV, + SSR: process.env.SSR, +}; diff --git a/apps/jetstream-web-extension/environments/environment.test.ts b/apps/jetstream-web-extension/environments/environment.test.ts new file mode 100644 index 000000000..f16c251bc --- /dev/null +++ b/apps/jetstream-web-extension/environments/environment.test.ts @@ -0,0 +1,12 @@ +export const environment = { + name: 'JetstreamTest', + production: true, + rollbarClientAccessToken: process.env.NX_PUBLIC_ROLLBAR_KEY, + amplitudeToken: process.env.NX_PUBLIC_AMPLITUDE_KEY, + authAudience: process.env.NX_PUBLIC_AUTH_AUDIENCE, + // MODE: process.env.MODE, + // BASE_URL: process.env.BASE_URL, + // PROD: process.env.PROD, + // DEV: process.env.DEV, + // SSR: process.env.SSR, +}; diff --git a/apps/jetstream-web-extension/environments/environment.ts b/apps/jetstream-web-extension/environments/environment.ts new file mode 100644 index 000000000..d8de25c9e --- /dev/null +++ b/apps/jetstream-web-extension/environments/environment.ts @@ -0,0 +1,15 @@ +// This file can be replaced during build by using the `fileReplacements` array. +// When building for production, this file is replaced with `environment.prod.ts`. + +export const environment = { + name: 'JetstreamDev', + production: false, + rollbarClientAccessToken: process.env.NX_PUBLIC_ROLLBAR_KEY, + amplitudeToken: process.env.NX_PUBLIC_AMPLITUDE_KEY, + authAudience: process.env.NX_PUBLIC_AUTH_AUDIENCE, + MODE: process.env.MODE, + BASE_URL: process.env.BASE_URL, + PROD: process.env.PROD, + DEV: process.env.DEV, + SSR: process.env.SSR, +}; diff --git a/apps/jetstream-web-extension/project.json b/apps/jetstream-web-extension/project.json new file mode 100644 index 000000000..49043397c --- /dev/null +++ b/apps/jetstream-web-extension/project.json @@ -0,0 +1,65 @@ +{ + "name": "jetstream-web-extension", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "apps/jetstream-web-extension/src", + "projectType": "application", + "tags": [], + "targets": { + "build": { + "executor": "@nx/webpack:webpack", + "outputs": ["{options.outputPath}"], + "defaultConfiguration": "production", + "options": { + "compiler": "babel", + "outputPath": "dist/apps/jetstream-web-extension", + "baseHref": "/", + "tsConfig": "apps/jetstream-web-extension/tsconfig.app.json", + "assets": ["apps/jetstream-web-extension/src/assets"], + "styles": [], + "scripts": [], + "webpackConfig": "apps/jetstream-web-extension/webpack.config.js" + }, + "configurations": { + "development": { + "extractLicenses": false, + "optimization": false, + "sourceMap": true, + "vendorChunk": true + }, + "production": { + "fileReplacements": [], + "optimization": false, + "outputHashing": "none", + "sourceMap": true, + "namedChunks": false, + "extractLicenses": false, + "vendorChunk": false + } + } + }, + "serve": { + "executor": "@nx/webpack:dev-server", + "defaultConfiguration": "development", + "options": { + "buildTarget": "jetstream-web-extension:build", + "hmr": true + }, + "configurations": { + "development": { + "buildTarget": "jetstream-web-extension:build:development" + }, + "production": { + "buildTarget": "jetstream-web-extension:build:production", + "hmr": false + } + } + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "apps/jetstream-web-extension/jest.config.ts" + } + } + } +} diff --git a/apps/jetstream-web-extension/src/components/Button.tsx b/apps/jetstream-web-extension/src/components/Button.tsx new file mode 100644 index 000000000..1c8f2224a --- /dev/null +++ b/apps/jetstream-web-extension/src/components/Button.tsx @@ -0,0 +1,232 @@ +/* eslint-disable no-restricted-globals */ +import { css } from '@emotion/react'; +import { isValidSalesforceRecordId, useInterval } from '@jetstream/shared/ui-utils'; +import { Maybe } from '@jetstream/types'; +import { Grid, GridCol, Icon, OutsideClickHandler } from '@jetstream/ui'; +import { fromAppState, JetstreamIcon, JetstreamLogo } from '@jetstream/ui-core'; +import { sendMessage } from '@jetstream/web-extension-utils'; +import { useEffect, useState } from 'react'; +import { useSetRecoilState } from 'recoil'; + +let currentPathname: string | undefined; +let currentRecordId: string | undefined; + +function getRecordPageRecordId() { + const pathname = location.pathname; + if (pathname === currentPathname) { + return currentRecordId; + } + currentPathname = pathname; + let recordId: string | undefined; + if (/\/[a-z0-9_]+\/[a-z0-9]{18}\/view$/i.test(pathname)) { + // extract the record id by matching [a-zA-Z0-9]{18} + recordId = pathname.match(/[a-zA-Z0-9]{18}/i)?.[0]; + } else if (/^\/[a-zA-Z0-9]{15}$/.test(pathname)) { + recordId = pathname.match(/\/[a-z0-9]{15}$/i)?.[0]; + } + if (isValidSalesforceRecordId(recordId)) { + currentRecordId = recordId; + return recordId; + } + return recordId; +} + +export function Button() { + const [isOnSalesforcePage] = useState( + () => !!document.querySelector('body.sfdcBody, body.ApexCSIPage, #auraLoadingBox') || location.host.endsWith('visualforce.com') + ); + /** + * TODO: Should we make the user sign in instead of using cookies? + * increases friction, but more secure + */ + const [sfHost, setSfHost] = useState>(null); + const [isOpen, setIsOpen] = useState(false); + const [recordId, setRecordId] = useState(() => getRecordPageRecordId()); + const setSelectedOrgId = useSetRecoilState(fromAppState.selectedOrgIdState); + const setSalesforceOrgs = useSetRecoilState(fromAppState.salesforceOrgsState); + + // check to see if the url changed and update the id + // TODO: figure out if there is a better way to listen for url change events + useInterval(() => setRecordId(getRecordPageRecordId), 5000); + + useEffect(() => { + if (isOnSalesforcePage) { + sendMessage({ + message: 'GET_SF_HOST', + data: { url: location.href }, + }) + .then((salesforceHost) => { + setSfHost(salesforceHost); + (async () => { + try { + if (salesforceHost) { + const sessionInfo = await sendMessage({ + message: 'GET_SESSION', + data: { salesforceHost }, + }); + console.log('sessionInfo', sessionInfo); + if (sessionInfo) { + const { org } = await sendMessage({ + message: 'INIT_ORG', + data: { sessionInfo }, + }); + setSalesforceOrgs([org]); + setSelectedOrgId(org.uniqueId); + } + } + } catch (ex) { + console.error(ex); + // FIXME: we need to tell the user there was a problem - most likely they are not logged in + } + })(); + }) + .catch((err) => { + console.log(err); + }); + } + }, [isOnSalesforcePage, setSalesforceOrgs, setSelectedOrgId]); + + // async function handleClick() { + // console.log('click'); + + // if (sfHost) { + // getSession(sfHost).then(async (session) => { + // console.log('session', session); + // // split by `!` to get the org id + // if (session) { + // const conn = new Connection({ + // instanceUrl: `https://${session.hostname}`, + // accessToken: session.key, + // }); + // console.log(conn); + // const results = await conn.identity(); + // console.log(results); + // } + // }); + // } + // } + + if (!isOnSalesforcePage || !sfHost) { + return null; + } + + return ( + <> + + {isOpen && ( + setIsOpen(false)} + > + + {/* TODO: optimize the logo */} + +
+ +
+
+
+ + {/* FIXME: better type safety on the params */} + + + Query Records + + + + + Load Records + + + {recordId && ( + <> +
+ + + View Current Record + + + + + Edit Current Record + + + + )} +
+
+
+ )} + + ); +} + +export default Button; diff --git a/apps/jetstream-web-extension/src/contentScript.tsx b/apps/jetstream-web-extension/src/contentScript.tsx new file mode 100644 index 000000000..526d8dc3a --- /dev/null +++ b/apps/jetstream-web-extension/src/contentScript.tsx @@ -0,0 +1,29 @@ +/* eslint-disable no-restricted-globals */ +/// +import { initAndRenderReact } from '@jetstream/web-extension-utils'; +import Button from './components/Button'; +import { AppWrapperNotJetstreamOwnedPage } from './core/AppWrapperNotJetstreamOwnedPage'; + +/** + * This will change `publicPath` to `chrome-extension:///`. + * It for runtime to get script chunks from the output folder + * and for asset modules like file-loader to work. + */ +// @ts-expect-error - see above comment +__webpack_public_path__ = chrome.runtime.getURL(''); + +// @ts-expect-error - see above comment +console.log('Content script loaded.', __webpack_public_path__); + +const elementId = 'jetstream-app-container'; + +const app = document.createElement('div'); +app.id = elementId; +document.body.appendChild(app); + +initAndRenderReact( + + + + ); +} diff --git a/apps/jetstream-web-extension/src/pages/options/options.html b/apps/jetstream-web-extension/src/pages/options/options.html new file mode 100644 index 000000000..997d6d0bb --- /dev/null +++ b/apps/jetstream-web-extension/src/pages/options/options.html @@ -0,0 +1,13 @@ + + + + + + Jetstream + + +
+ +
+ + diff --git a/apps/jetstream-web-extension/src/pages/popup/Popup.tsx b/apps/jetstream-web-extension/src/pages/popup/Popup.tsx new file mode 100644 index 000000000..09d03f50e --- /dev/null +++ b/apps/jetstream-web-extension/src/pages/popup/Popup.tsx @@ -0,0 +1,46 @@ +/* eslint-disable no-restricted-globals */ +import { JetstreamLogoInverse } from '@jetstream/ui-core'; +import { initAndRenderReact } from '@jetstream/web-extension-utils'; +import { AppWrapperNotJetstreamOwnedPage } from '../../core/AppWrapperNotJetstreamOwnedPage'; + +initAndRenderReact(); + +export function Component() { + function handleClick() { + console.log('click'); + + // chrome.tabs.query({ active: true, lastFocusedWindow: true }, (tabs) => { + // console.log('tabs', tabs); + // const url = tabs?.[0]?.url; + // console.log('url', url); + // if (url) { + // sendMessage({ message: 'GET_SESSION', data: { salesforceHost: new URL(url).host } }).then(async (session) => { + // console.log('session', session); + // // split by `!` to get the org id + // // if (session) { + // // const result = await apiGetRest({ + // // method: 'GET', + // // session, + // // url: '/services/data', + // // }); + // // console.log(result); + // // // const conn = new Connection({ + // // // instanceUrl: `https://${session.hostname}`, + // // // accessToken: session.key, + // // // }); + // // // console.log(conn); + // // // const results = await conn.identity(); + // // // console.log(results); + // // } + // }); + // } + // }); + } + + return ( + + + + + ); +} diff --git a/apps/jetstream-web-extension/src/pages/popup/popup.html b/apps/jetstream-web-extension/src/pages/popup/popup.html new file mode 100644 index 000000000..a15edfc31 --- /dev/null +++ b/apps/jetstream-web-extension/src/pages/popup/popup.html @@ -0,0 +1,11 @@ + + + + + + Jetstream + + +
+ + diff --git a/apps/jetstream-web-extension/src/serviceWorker.ts b/apps/jetstream-web-extension/src/serviceWorker.ts new file mode 100644 index 000000000..7f4f343fb --- /dev/null +++ b/apps/jetstream-web-extension/src/serviceWorker.ts @@ -0,0 +1,212 @@ +/* eslint-disable no-restricted-globals */ +/** + * This file is based on the Salesforce Inspector extension for Chrome. (MIT license) + * Credit: https://github.com/sorenkrabbe/Chrome-Salesforce-inspector/blob/master/addon/background.js + */ +/// +/// +import { logger } from '@jetstream/shared/client-logger'; +import { HTTP } from '@jetstream/shared/constants'; +import { GetPageUrl, GetSession, GetSfHost, InitOrg, Message, MessageResponse, OrgAndSessionInfo } from '@jetstream/web-extension-utils'; +import { Method } from 'tiny-request-router'; +import { extensionRoutes } from './controllers/extension.routes'; +import { initializeStorageWithDefaults } from './storage'; +import { initApiClient, initApiClientAndOrg } from './utils/api-client'; + +const ctx: ServiceWorkerGlobalScope = self as any; + +console.log('Jetstream Service worker loaded.'); + +let connections: Record = {}; + +// connections seem to continually get reset +// and we cannot make async calls in the fetch event listener to get from storage +// could not find any other lifecycle callback that seemed to get called on startup +setInterval(() => { + getConnections(); +}, 1000); + +async function getConnections(): Promise> { + const storage = await chrome.storage.local.get(['connections']); + storage.connections = storage.connections || {}; + connections = storage.connections; + return storage.connections; +} + +async function setConnection(key: string, data: OrgAndSessionInfo) { + const _connections = await getConnections(); + _connections[key] = data; + connections = _connections; + await chrome.storage.local.set({ connections }); +} + +chrome.runtime.onInstalled.addListener(async () => { + await initializeStorageWithDefaults({}); + console.log('Jetstream Extension successfully installed!'); +}); + +chrome.runtime.onStartup.addListener(() => console.log('[SW EVENT] onStartup')); +chrome.runtime.onSuspend.addListener(() => console.log('[SW EVENT] onSuspend')); +chrome.runtime.onConnect.addListener((ev) => console.log('[SW EVENT] onConnect', ev)); +chrome.runtime.onConnectExternal.addListener((ev) => console.log('[SW EVENT] onConnectExternal', ev)); +chrome.runtime.onMessageExternal.addListener((ev) => console.log('[SW EVENT] onMessageExternal', ev)); +chrome.runtime.onRestartRequired.addListener((ev) => console.log('[SW EVENT] onRestartRequired', ev)); +chrome.runtime.onSuspendCanceled.addListener(() => console.log('[SW EVENT] onSuspendCanceled')); +chrome.runtime.onUpdateAvailable.addListener((ev) => console.log('[SW EVENT] onUpdateAvailable', ev)); +chrome.runtime.onUserScriptConnect.addListener((ev) => console.log('[SW EVENT] onUserScriptConnect', ev)); +chrome.runtime.onUserScriptMessage.addListener((ev) => console.log('[SW EVENT] onUserScriptMessage', ev)); + +chrome.runtime.onMessage.addListener( + (request: Message['request'], sender: chrome.runtime.MessageSender, sendResponse: (response: MessageResponse) => void) => { + console.log('[SW EVENT] onMessage', request); + getConnections(); // ensure connections get initialized + switch (request.message) { + case 'GET_SF_HOST': { + handleGetSalesforceHostWithApiAccess(request.data, sender) + .then((data) => handleResponse(data, sendResponse)) + .catch(handleError(sendResponse)); + return true; // indicate that sendResponse will be called asynchronously + } + case 'GET_SESSION': { + handleGetSession(request.data, sender) + .then((data) => handleResponse(data, sendResponse)) + .catch(handleError(sendResponse)); + return true; // indicate that sendResponse will be called asynchronously + } + case 'GET_PAGE_URL': { + handleResponse(handleGetPageUrl(request.data.page), sendResponse); + return true; // indicate that sendResponse will be called asynchronously + } + case 'INIT_ORG': { + handleInitOrg(request.data, sender) + .then((data) => { + handleResponse({ org: data.org }, sendResponse); + }) + .catch(handleError(sendResponse)); + return true; // indicate that sendResponse will be called asynchronously + } + default: + console.warn(`Unknown message`, request); + return false; + } + } +); + +ctx.addEventListener('fetch', (event: FetchEvent) => { + const url = new URL(event.request.url); + const { method } = event.request; + const pathname = url.pathname; + if (!url.origin.startsWith('chrome-extension') || !url.pathname.startsWith('/api')) { + return; + } + + logger.debug('[FETCH]', { event }); + const route = extensionRoutes.match(method as Method, pathname); + + if (!route) { + event.respondWith(new Response('Not found', { status: 404 })); + return; + } + + const orgHeader = event.request.headers.get(HTTP.HEADERS.X_SFDC_ID); + const connection = connections[orgHeader || '_placeholder_']; + if (!orgHeader || !connection) { + event.respondWith( + (async () => { + return route.handler({ + event, + params: route.params, + }); + })() + ); + } else { + if (!connection) { + event.respondWith(new Response('Not found', { status: 404 })); + return; + } + + const { sessionInfo, org } = connection; + const apiConnection = initApiClient(sessionInfo); + + // TODO: use zod to validate input on web extension and server + + event.respondWith( + (async () => { + return route.handler({ + event, + params: route.params, + // user: {}, + jetstreamConn: apiConnection, + // targetJetstreamConn + org, + // targetOrg + }); + })() + ); + } +}); + +/** + * HELPER FUNCTIONS + */ + +function getCookieStoreId(sender: chrome.runtime.MessageSender) { + return (sender?.tab as any)?.cookieStoreId; +} + +const handleResponse = (data: Message['response'], sendResponse: (response: MessageResponse) => void) => { + console.log('RESPONSE', data); + sendResponse({ data }); +}; + +const handleError = (sendResponse: (response: MessageResponse) => void) => (err: unknown) => { + console.log('ERROR', err); + sendResponse({ data: null }); +}; + +/** + * HANDLERS + */ + +async function handleGetSalesforceHostWithApiAccess( + { url }: GetSfHost['request']['data'], + sender: chrome.runtime.MessageSender +): Promise { + const orgId = await chrome.cookies + .get({ url, name: 'sid', storeId: getCookieStoreId(sender) }) + .then((cookie) => cookie?.value?.split('!')?.[0]); + + const results = await Promise.all([ + chrome.cookies.getAll({ name: 'sid', domain: 'salesforce.com', secure: true, storeId: getCookieStoreId(sender) }), + chrome.cookies.getAll({ name: 'sid', domain: 'cloudforce.com', secure: true, storeId: getCookieStoreId(sender) }), + ]); + return results.flat().find(({ value }) => value.startsWith(orgId + '!'))?.domain; +} + +async function handleGetSession( + { salesforceHost }: GetSession['request']['data'], + sender: chrome.runtime.MessageSender +): Promise { + const sessionCookie = await chrome.cookies.get({ + url: `https://${salesforceHost}`, + name: 'sid', + storeId: getCookieStoreId(sender), + }); + if (!sessionCookie) { + return null; + } + return { key: sessionCookie.value, hostname: sessionCookie.domain }; +} + +function handleGetPageUrl(page: string): GetPageUrl['response'] { + return chrome.runtime.getURL(page); +} + +async function handleInitOrg( + { sessionInfo }: InitOrg['request']['data'], + sender: chrome.runtime.MessageSender +): Promise { + const response = await initApiClientAndOrg(sessionInfo); + await setConnection(response.org.uniqueId, { sessionInfo, org: response.org }); + return response; +} diff --git a/apps/jetstream-web-extension/src/storage.ts b/apps/jetstream-web-extension/src/storage.ts new file mode 100644 index 000000000..f76395324 --- /dev/null +++ b/apps/jetstream-web-extension/src/storage.ts @@ -0,0 +1,56 @@ +// Define your storage data here +export interface Storage {} // eslint-disable-line + +export function getStorageData(): Promise { + return new Promise((resolve, reject) => { + chrome.storage.local.get(null, (result) => { + if (chrome.runtime.lastError) { + return reject(chrome.runtime.lastError); + } + + return resolve(result as Storage); + }); + }); +} + +export function setStorageData(data: Storage): Promise { + return new Promise((resolve, reject) => { + chrome.storage.local.set(data, () => { + if (chrome.runtime.lastError) { + return reject(chrome.runtime.lastError); + } + + return resolve(); + }); + }); +} + +export function getStorageItem(key: Key): Promise { + return new Promise((resolve, reject) => { + chrome.storage.local.get([key], (result) => { + if (chrome.runtime.lastError) { + return reject(chrome.runtime.lastError); + } + + return resolve((result as Storage)[key]); + }); + }); +} + +export function setStorageItem(key: Key, value: Storage[Key]): Promise { + return new Promise((resolve, reject) => { + chrome.storage.local.set({ [key]: value }, () => { + if (chrome.runtime.lastError) { + return reject(chrome.runtime.lastError); + } + + return resolve(); + }); + }); +} + +export async function initializeStorageWithDefaults(defaults: Storage) { + const currentStorageData = await getStorageData(); + const newStorageData = Object.assign({}, defaults, currentStorageData); + await setStorageData(newStorageData); +} diff --git a/apps/jetstream-web-extension/src/utils/api-client.ts b/apps/jetstream-web-extension/src/utils/api-client.ts new file mode 100644 index 000000000..990f790b5 --- /dev/null +++ b/apps/jetstream-web-extension/src/utils/api-client.ts @@ -0,0 +1,72 @@ +import { ApiConnection, getApiRequestFactoryFn } from '@jetstream/salesforce-api'; +import { SalesforceOrgUi, SObjectOrganization } from '@jetstream/types'; +import { OrgAndApiConnection, SessionInfo } from '@jetstream/web-extension-utils'; + +export function initApiClient({ key: accessToken, hostname }: SessionInfo): ApiConnection { + const instanceUrl = `https://${hostname}`; + return new ApiConnection({ + apiRequestAdapter: getApiRequestFactoryFn(fetch), + userId: 'unknown', + organizationId: 'unknown', + accessToken, + apiVersion: '61.0', + instanceUrl, + // refreshToken: refresh_token, + logging: true, // TODO: make dynamic from options + }); +} + +export async function initApiClientAndOrg(sessionInfo: SessionInfo): Promise { + const instanceUrl = `https://${sessionInfo.hostname}`; + + const apiConnection = initApiClient(sessionInfo); + + const versions = await apiConnection.org.apiVersions(); + const apiVersion = versions.reverse()[0].version; + + // https://login.salesforce.com/id/00D6g000008KX1jEAG/0056g000004tCpaAAE + const [userId, organizationId] = (await apiConnection.org.discovery()).identity.split('/').reverse().slice(0, 2); + + apiConnection.updateSessionInfo({ apiVersion, userId, organizationId }); + + const identity = await apiConnection.org.identity(); + + let companyInfoRecord: SObjectOrganization | undefined; + + try { + const { queryResults } = await await apiConnection.query.query( + `SELECT Id, Name, Country, OrganizationType, InstanceName, IsSandbox, LanguageLocaleKey, NamespacePrefix, TrialExpirationDate FROM Organization` + ); + if (queryResults.totalSize > 0) { + companyInfoRecord = queryResults.records[0]; + } + } catch (ex) { + console.warn('Error getting org info %o', ex); + } + + const org: SalesforceOrgUi = { + uniqueId: identity.organization_id, + label: identity.username, + filterText: identity.username, + accessToken: sessionInfo.key, + instanceUrl, + loginUrl: instanceUrl, + userId: identity.user_id, + email: identity.email, + organizationId: identity.organization_id, + username: identity.username, + displayName: identity.display_name, + thumbnail: identity.photos?.thumbnail, + orgName: companyInfoRecord?.Name || 'Unknown Organization', + orgCountry: companyInfoRecord?.Country, + orgOrganizationType: companyInfoRecord?.OrganizationType, + orgInstanceName: companyInfoRecord?.InstanceName, + orgIsSandbox: companyInfoRecord?.IsSandbox, + orgLanguageLocaleKey: companyInfoRecord?.LanguageLocaleKey, + orgNamespacePrefix: companyInfoRecord?.NamespacePrefix, + orgTrialExpirationDate: companyInfoRecord?.TrialExpirationDate, + }; + + // return new ApiClient(sessionInfo, apiVersion, org); + return { org, apiConnection }; +} diff --git a/apps/jetstream-web-extension/src/utils/monaco-loader.ts b/apps/jetstream-web-extension/src/utils/monaco-loader.ts new file mode 100644 index 000000000..e4a220e5a --- /dev/null +++ b/apps/jetstream-web-extension/src/utils/monaco-loader.ts @@ -0,0 +1,23 @@ +import { logger } from '@jetstream/shared/client-logger'; +import { logErrorToRollbar } from '@jetstream/shared/ui-utils'; +import { getErrorMessageAndStackObj } from '@jetstream/shared/utils'; +import { loader } from '@monaco-editor/react'; + +// Load as static resource instead of bundled in application +// this prevents webpack from needing to process anything +loader.config({ paths: { vs: '/assets/js/monaco/vs' } }); + +loader + .init() + .then(async (monaco) => { + // Load all custom configuration + const jetstreamMonaco = await import('@jetstream/monaco'); + jetstreamMonaco.configure(monaco); + }) + .catch((ex) => { + logger.error('[ERROR] Failed to load monaco editor', ex); + logErrorToRollbar('Failed to load monaco editor', { + ...getErrorMessageAndStackObj(ex), + exception: ex, + }); + }); diff --git a/apps/jetstream-web-extension/tsconfig.app.json b/apps/jetstream-web-extension/tsconfig.app.json new file mode 100644 index 000000000..0f85b1d31 --- /dev/null +++ b/apps/jetstream-web-extension/tsconfig.app.json @@ -0,0 +1,23 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "types": ["node", "google.accounts", "google.picker", "gapi.auth2", "gapi.client.drive"] + }, + "exclude": [ + "src/**/*.spec.ts", + "src/**/*.test.ts", + "src/**/*.spec.tsx", + "src/**/*.test.tsx", + "src/**/*.spec.js", + "src/**/*.test.js", + "src/**/*.spec.jsx", + "src/**/*.test.jsx" + ], + "include": ["src/**/*.js", "src/**/*.jsx", "src/**/*.ts", "src/**/*.tsx"], + "files": [ + "../../node_modules/@nx/react/typings/cssmodule.d.ts", + "../../node_modules/@nx/react/typings/image.d.ts", + "../../custom-typings/index.d.ts" + ] +} diff --git a/apps/jetstream-web-extension/tsconfig.json b/apps/jetstream-web-extension/tsconfig.json new file mode 100644 index 000000000..ae449ddd4 --- /dev/null +++ b/apps/jetstream-web-extension/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "allowJs": false, + "allowSyntheticDefaultImports": true, + "esModuleInterop": false, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "jsx": "react-jsx", + "jsxImportSource": "@emotion/react", + "lib": ["DOM", "DOM.Iterable", "ESNext"], + "module": "ESNext", + "moduleResolution": "Node", + "noEmit": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "strictNullChecks": true, + "types": ["chrome-types", "google.accounts", "google.picker", "gapi.auth2", "gapi.client.drive"] + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.app.json" + }, + { + "path": "./tsconfig.spec.json" + } + ], + "extends": "../../tsconfig.base.json" +} diff --git a/apps/jetstream-web-extension/tsconfig.spec.json b/apps/jetstream-web-extension/tsconfig.spec.json new file mode 100644 index 000000000..6306666a1 --- /dev/null +++ b/apps/jetstream-web-extension/tsconfig.spec.json @@ -0,0 +1,19 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "types": ["node"] + }, + "include": [ + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.test.tsx", + "src/**/*.spec.tsx", + "src/**/*.test.js", + "src/**/*.spec.js", + "src/**/*.test.jsx", + "src/**/*.spec.jsx", + "src/**/*.d.ts" + ], + "files": ["../../node_modules/@nx/react/typings/cssmodule.d.ts", "../../node_modules/@nx/react/typings/image.d.ts"] +} diff --git a/apps/jetstream-web-extension/webpack.config.js b/apps/jetstream-web-extension/webpack.config.js new file mode 100644 index 000000000..0e2323615 --- /dev/null +++ b/apps/jetstream-web-extension/webpack.config.js @@ -0,0 +1,139 @@ +const { composePlugins, withNx } = require('@nx/webpack'); +const { withReact } = require('@nx/react'); +const CopyWebpackPlugin = require('copy-webpack-plugin'); +// const CrxLoadScriptWebpackPlugin = require('@cooby/crx-load-script-webpack-plugin'); +const HtmlWebpackPlugin = require('html-webpack-plugin'); +const path = require('path'); +// const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin'); +const webpack = require('webpack'); + +// Nx plugins for webpack. +// @ts-expect-error withReact is complaining about the type of the config - but works on some machines just fine +module.exports = composePlugins(withNx(), withReact(), (config) => { + const isDev = config.mode === 'development'; + config.devtool = isDev ? 'inline-source-map' : 'source-map'; + + // // @ts-expect-error typescript type is not correctly detected + // config.devServer = { + // // @ts-expect-error typescript type is not correctly detected + // ...config.devServer, + // hot: true, + // /** + // * We need devServer write files to disk, + // * But don't want it reload whole page because of the output file changes. + // */ + // static: { watch: false }, + // // static: true, + // /** + // * Set WebSocket url to dev-server, instead of the default `${publicPath}/ws` + // */ + // client: { + // webSocketURL: 'ws://localhost:4201/ws', + // }, + // /** + // * The host of the page of your script extension runs on. + // * You'll see `[webpack-dev-server] Invalid Host/Origin header` if this is not set. + // * preceding period is a wildcard for subdomains + // */ + // allowedHosts: ['.salesforce.com', '.visual.force.com', '.lightning.force.com', '.cloudforce.com', '.visualforce.com'], + // devMiddleware: { + // // @ts-expect-error typescript type is not correctly detected + // ...config.devServer?.devMiddleware, + // /** + // * Write file to output folder /build, so we can execute it later. + // */ + // writeToDisk: true, + // }, + // }; + + config.entry = { + app: './src/pages/app/app.tsx', + popup: './src/pages/popup/Popup.tsx', + options: './src/pages/options/Options.tsx', + serviceWorker: './src/serviceWorker.ts', + contentScript: './src/contentScript.tsx', + }; + config.output = { + filename: '[name].js', + path: config.output?.path, + clean: true, + // publicPath: 'http://localhost:4201/', + // publicPath: ASSET_PATH, + }; + config.resolve = { + ...config.resolve, + }; + // config.optimization = { + // minimize: false, + // }; + // if runtime chunk is enabled, then the service worker will not work + config.optimization = { + ...config.optimization, + runtimeChunk: false, + sideEffects: true, + splitChunks: false, + }; + config.plugins = config.plugins || []; + config.plugins.push( + // @ts-expect-error not sure why this is saying invalid type + // TODO: do I need to remove existing plugins? + // ...(isDev + // ? [ + // new CrxLoadScriptWebpackPlugin(), + // new ReactRefreshWebpackPlugin({ + // overlay: false, + // }), + // ] + // : []), + new webpack.EnvironmentPlugin({ + NX_PUBLIC_AUTH_AUDIENCE: 'http://getjetstream.app/app_metadata', + NX_PUBLIC_AMPLITUDE_KEY: '', + NX_PUBLIC_ROLLBAR_KEY: '', + }), + createHtmlPagePlugin('app'), + createHtmlPagePlugin('popup'), + createHtmlPagePlugin('options'), + new CopyWebpackPlugin({ + patterns: [ + { + from: 'src/manifest.json', + to: config.output.path, + force: true, + // transform: function (content, path) { + // if (!isDev) { + // return content; + // } + // /** + // * @type {chrome.runtime.ManifestV3} + // */ + // const manifest = JSON.parse(content.toString()); + // manifest.permissions?.push('scripting'); + // manifest.web_accessible_resources?.[0].resources?.push('*.hot-update.json'); + // // TODO: add versioning + // return Buffer.from(JSON.stringify(manifest, null, 2)); + // // generates the manifest file using the package.json information + // // return Buffer.from( + // // JSON.stringify({ + // // description: process.env.npm_package_description, + // // version: process.env.npm_package_version, + // // ...JSON.parse(content.toString()), + // // }) + // // ); + // }, + }, + ], + }) + ); + + return config; +}); + +function createHtmlPagePlugin(moduleName) { + const filename = `${moduleName.toLowerCase()}.html`; + return new HtmlWebpackPlugin({ + template: path.join(__dirname, 'src', 'pages', moduleName, filename), + filename, + chunks: [moduleName.toLowerCase()], + cache: false, + }); +} diff --git a/apps/jetstream/src/app/AppRoutes.tsx b/apps/jetstream/src/app/AppRoutes.tsx index dcdd7e12c..9c9dc9208 100644 --- a/apps/jetstream/src/app/AppRoutes.tsx +++ b/apps/jetstream/src/app/AppRoutes.tsx @@ -1,10 +1,8 @@ import { Maybe, UserProfileUi } from '@jetstream/types'; +import { APP_ROUTES, AppHome, OrgSelectionRequired } from '@jetstream/ui-core'; import { useEffect } from 'react'; import { Navigate, Route, Routes, useLocation } from 'react-router-dom'; import lazy from './components/core/LazyLoad'; -import { APP_ROUTES } from './components/core/app-routes'; -import { AppHome } from './components/home/AppHome'; -import OrgSelectionRequired from './components/orgs/OrgSelectionRequired'; const LoadRecords = lazy(() => import('@jetstream/feature/load-records').then((module) => ({ default: module.LoadRecords }))); const LoadRecordsMultiObject = lazy(() => diff --git a/apps/jetstream/src/app/app.tsx b/apps/jetstream/src/app/app.tsx index 3b61dbd5a..86f573d8a 100644 --- a/apps/jetstream/src/app/app.tsx +++ b/apps/jetstream/src/app/app.tsx @@ -1,7 +1,7 @@ import { Maybe, UserProfileUi } from '@jetstream/types'; import { AppToast, ConfirmationServiceProvider } from '@jetstream/ui'; // import { initSocket } from '@jetstream/shared/data'; -import { DownloadFileStream, ErrorBoundaryFallback } from '@jetstream/ui-core'; +import { AppLoading, DownloadFileStream, ErrorBoundaryFallback, HeaderNavbar } from '@jetstream/ui-core'; import { OverlayProvider } from '@react-aria/overlays'; import { Suspense, useEffect, useState } from 'react'; import { DndProvider } from 'react-dnd'; @@ -12,9 +12,7 @@ import { RecoilRoot } from 'recoil'; import { environment } from '../environments/environment'; import { AppRoutes } from './AppRoutes'; import AppInitializer from './components/core/AppInitializer'; -import AppLoading from './components/core/AppLoading'; import AppStateResetOnOrgChange from './components/core/AppStateResetOnOrgChange'; -import HeaderNavbar from './components/core/HeaderNavbar'; import LogInitializer from './components/core/LogInitializer'; import NotificationsRequestModal from './components/core/NotificationsRequestModal'; import './components/core/monaco-loader'; diff --git a/apps/jetstream/src/main.tsx b/apps/jetstream/src/main.tsx index b079c63ef..fc9c5ce6b 100644 --- a/apps/jetstream/src/main.tsx +++ b/apps/jetstream/src/main.tsx @@ -4,16 +4,10 @@ import { CONFIG } from './app/components/core/config'; // DO NOT CHANGE ORDER OF IMPORTS import '@salesforce-ux/design-system/assets/styles/salesforce-lightning-design-system.min.css'; -// import React from 'react'; -// import { render } from 'react-dom'; -import { setJobsWorker } from '@jetstream/ui-core'; import { createRoot } from 'react-dom/client'; import App from './app/app'; import './main.scss'; -const jobsWorker = new Worker(new URL('./workers/jobs.worker.ts', import.meta.url), { type: 'module' }); -setJobsWorker(jobsWorker); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const container = document.getElementById('root')!; diff --git a/apps/jetstream/src/workers/jobs.worker.ts b/apps/jetstream/src/workers/jobs.worker.ts index b72211662..dcbf8f9eb 100644 --- a/apps/jetstream/src/workers/jobs.worker.ts +++ b/apps/jetstream/src/workers/jobs.worker.ts @@ -1,55 +1,13 @@ /// /* eslint-disable @typescript-eslint/no-explicit-any */ -import { - base64ToArrayBuffer, - getOrgUrlParams, - pollBulkApiJobUntilDone, - pollRetrieveMetadataResultsUntilDone, - prepareCsvFile, - prepareExcelFile, -} from '@jetstream/shared/browser-worker-utils'; import { logger } from '@jetstream/shared/client-logger'; -import { MIME_TYPES } from '@jetstream/shared/constants'; -import { - bulkApiAddBatchToJob, - bulkApiCreateJob, - queryMore, - retrieveMetadataFromListMetadata, - retrieveMetadataFromManifestFile, - retrieveMetadataFromPackagesNames, - sobjectOperation, -} from '@jetstream/shared/data'; -import { - ensureArray, - flattenRecords, - getErrorMessage, - getIdFromRecordUrl, - getMapOfBaseAndSubqueryRecords, - getSObjectFromRecordUrl, - replaceSubqueryQueryResultsWithRecords, - splitArrayToMaxSize, -} from '@jetstream/shared/utils'; -import type { - AsyncJobType, - AsyncJobWorkerMessagePayload, - AsyncJobWorkerMessageResponse, - BulkDownloadJob, - CancelJob, - RetrievePackageFromListMetadataJob, - RetrievePackageFromManifestJob, - RetrievePackageFromPackageNamesJob, - SalesforceRecord, - UploadToGoogleJob, - WorkerMessage, -} from '@jetstream/types'; -import clamp from 'lodash/clamp'; -import isString from 'lodash/isString'; +import type { AsyncJobType, AsyncJobWorkerMessagePayload, WorkerMessage } from '@jetstream/types'; +import { JobWorker } from '@jetstream/ui-core'; declare const self: DedicatedWorkerGlobalScope; logger.log('[JOBS WORKER] INITIALIZED'); -// Updated if a job is attempted to be cancelled, the job will check this on each iteration and cancel if possible -const cancelledJobIds = new Set(); +const jobWorker = new JobWorker(replyToMessage); self.addEventListener('error', (event) => { console.log('WORKER ERROR', event); @@ -59,258 +17,9 @@ self.addEventListener('error', (event) => { self.addEventListener('message', (event) => { const payload: WorkerMessage = event.data; logger.info({ payload }); - handleMessage(payload.name, payload.data, event.ports?.[0]); + jobWorker.handleMessage(payload.name, payload.data, event.ports?.[0]); }); -async function handleMessage(name: AsyncJobType, payloadData: AsyncJobWorkerMessagePayload, port?: MessagePort) { - const { org, job } = payloadData || {}; - switch (name) { - case 'CancelJob': { - const { job } = payloadData as AsyncJobWorkerMessagePayload; - cancelledJobIds.add(job.id); - break; - } - case 'BulkDelete': { - try { - // TODO: add validation to ensure that we have at least one record - // also, we are assuming that all records are same SObject - const MAX_DELETE_RECORDS = 200; - - let { records, batchSize } = job.meta as { records: SalesforceRecord[]; batchSize?: number }; - records = Array.isArray(records) ? records : [records]; - - batchSize = clamp(batchSize || MAX_DELETE_RECORDS, 1, 200); - - const sobject = getSObjectFromRecordUrl(records[0].attributes.url); - const allIds: string[] = records.map((record) => getIdFromRecordUrl(record.attributes.url)); - - const results: any[] = []; - for (const ids of splitArrayToMaxSize(allIds, batchSize)) { - try { - // TODO: add progress notification and allow cancellation - let tempResults = await sobjectOperation(org, sobject, 'delete', { ids }, { allOrNone: false }); - tempResults = ensureArray(tempResults); - tempResults.forEach((result) => results.push(result)); - } catch (ex) { - logger.error('There was a problem deleting these records'); - } - } - - const response: AsyncJobWorkerMessageResponse = { job, results }; - replyToMessage(name, response); - } catch (ex) { - const response: AsyncJobWorkerMessageResponse = { job }; - replyToMessage(name, response, getErrorMessage(ex)); - } - break; - } - case 'BulkDownload': { - try { - const { org, job } = payloadData as AsyncJobWorkerMessagePayload; - const { - serverUrl, - sObject, - soql, - isTooling, - fields, - records, - hasAllRecords, - includeDeletedRecords, - useBulkApi, - fileFormat, - fileName, - subqueryFields, - includeSubquery, - googleFolder, - } = job.meta; - let mimeType: string; - let fileData; - let downloadedRecords = records; - - if (!useBulkApi) { - // eslint-disable-next-line prefer-const - let { nextRecordsUrl, totalRecordCount } = job.meta; - let done = !nextRecordsUrl; - - while (!done && nextRecordsUrl && !hasAllRecords) { - // emit progress - const results = { - done: false, - progress: Math.floor((downloadedRecords.length / Math.max(totalRecordCount, records.length)) * 100), - }; - const response: AsyncJobWorkerMessageResponse = { job, lastActivityUpdate: true, results }; - replyToMessage(name, response); - - const { queryResults } = await queryMore(org, nextRecordsUrl, isTooling).then(replaceSubqueryQueryResultsWithRecords); - done = queryResults.done; - nextRecordsUrl = queryResults.nextRecordsUrl; - downloadedRecords = downloadedRecords.concat(queryResults.records); - if (cancelledJobIds.has(job.id)) { - throw new Error('Job cancelled'); - } - } - } else { - // Submit bulk query job and poll until results are ready - // Main Browser context will handle downloading the file as a link so it can be streamed - const jobInfo = await bulkApiCreateJob(org, { type: includeDeletedRecords ? 'QUERY_ALL' : 'QUERY', sObject }); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const jobId = jobInfo.id!; - const batchResult = await bulkApiAddBatchToJob(org, jobId, soql, true); - - const response: AsyncJobWorkerMessageResponse = { job, lastActivityUpdate: true }; - replyToMessage(name, response, undefined); - - const finalResults = await pollBulkApiJobUntilDone(org, jobInfo, 1, { - onChecked: () => { - const response: AsyncJobWorkerMessageResponse = { job, lastActivityUpdate: true }; - replyToMessage(name, response, undefined); - }, - }); - - if (finalResults.batches?.[0].state === 'Failed') { - throw new Error(finalResults.batches[0].stateMessage); - } - - const results = { - done: true, - progress: 100, - mimeType: MIME_TYPES.CSV, - useBulkApi: true, - results: `${serverUrl}/static/bulk/${jobId}/${batchResult.id}/file?${getOrgUrlParams(org, { - type: 'result', - isQuery: 'true', - fileName, - })}`, - fileName, - fileFormat, - googleFolder, - }; - replyToMessage(name, { job, results }); - return; - } - - switch (fileFormat) { - case 'xlsx': { - if (includeSubquery && subqueryFields) { - fileData = prepareExcelFile(getMapOfBaseAndSubqueryRecords(downloadedRecords, fields, subqueryFields)); - } else { - fileData = prepareExcelFile(flattenRecords(downloadedRecords, fields), fields); - } - mimeType = MIME_TYPES.XLSX; - break; - } - case 'csv': { - fileData = prepareCsvFile(flattenRecords(downloadedRecords, fields), fields); - mimeType = MIME_TYPES.CSV; - break; - } - case 'json': { - fileData = JSON.stringify(downloadedRecords, null, 2); - mimeType = MIME_TYPES.JSON; - break; - } - case 'gdrive': { - if (includeSubquery && subqueryFields) { - fileData = prepareExcelFile(getMapOfBaseAndSubqueryRecords(downloadedRecords, fields, subqueryFields)); - } else { - fileData = prepareExcelFile(flattenRecords(downloadedRecords, fields), fields); - } - mimeType = MIME_TYPES.GSHEET; - break; - } - default: - throw new Error('A valid file type type has not been selected'); - } - - const results = { done: true, progress: 100, fileData, mimeType: '', fileName, fileFormat, googleFolder }; - - const response: AsyncJobWorkerMessageResponse = { job, results }; - replyToMessage(name, response); - } catch (ex) { - const response: AsyncJobWorkerMessageResponse = { job }; - replyToMessage(name, response, getErrorMessage(ex)); - } - break; - } - case 'UploadToGoogle': { - // Message is passed through to jobs.tsx for upload - try { - const { job } = payloadData as AsyncJobWorkerMessagePayload; - const response: AsyncJobWorkerMessageResponse = { job, results: job.meta }; - replyToMessage(name, response); - } catch (ex) { - const response: AsyncJobWorkerMessageResponse = { job }; - replyToMessage(name, response, getErrorMessage(ex)); - } - break; - } - case 'RetrievePackageZip': { - try { - const { org, job } = payloadData as AsyncJobWorkerMessagePayload< - RetrievePackageFromListMetadataJob | RetrievePackageFromManifestJob | RetrievePackageFromPackageNamesJob - >; - const { fileName, fileFormat, mimeType, uploadToGoogle, googleFolder } = job.meta; - - let id: string; - switch (job.meta.type) { - case 'listMetadata': { - id = (await retrieveMetadataFromListMetadata(org, job.meta.listMetadataItems)).id; - break; - } - case 'packageManifest': { - id = (await retrieveMetadataFromManifestFile(org, job.meta.packageManifest)).id; - break; - } - case 'packageNames': { - id = (await retrieveMetadataFromPackagesNames(org, job.meta.packageNames)).id; - break; - } - default: { - const response: AsyncJobWorkerMessageResponse = { job }; - replyToMessage(name, response, 'An invalid metadata type was provided'); - return; - } - } - - if (cancelledJobIds.has(job.id)) { - throw new Error('Job cancelled'); - } - - const results = await pollRetrieveMetadataResultsUntilDone(org, id, { - isCancelled: () => { - return cancelledJobIds.has(job.id); - }, - onChecked: () => { - const response: AsyncJobWorkerMessageResponse = { job, lastActivityUpdate: true }; - replyToMessage(name, response, undefined); - }, - }); - - if (isString(results.zipFile)) { - const fileData = base64ToArrayBuffer(results.zipFile); - const response: AsyncJobWorkerMessageResponse = { - job, - results: { fileData, mimeType, fileName, fileFormat, uploadToGoogle, googleFolder }, - }; - replyToMessage(name, response, undefined, fileData); - } else { - const response: AsyncJobWorkerMessageResponse = { job }; - replyToMessage(name, response, 'No file was provided from Salesforce'); - } - } catch (ex) { - const response: AsyncJobWorkerMessageResponse = { job }; - replyToMessage(name, response, getErrorMessage(ex)); - if (cancelledJobIds.has(job.id)) { - cancelledJobIds.delete(job.id); - } - } - break; - } - default: - break; - } -} - function replyToMessage(name: string, data: any, error?: any, transferrable?: any) { transferrable = transferrable ? [transferrable] : undefined; self.postMessage({ name, data, error }, transferrable); diff --git a/apps/jetstream/tsconfig.app.json b/apps/jetstream/tsconfig.app.json index 76dab0551..c4c2ffb3a 100644 --- a/apps/jetstream/tsconfig.app.json +++ b/apps/jetstream/tsconfig.app.json @@ -5,15 +5,6 @@ "types": ["node", "google.accounts", "google.picker", "gapi.auth2", "gapi.client.drive"] }, "exclude": ["**/*.spec.ts", "**/*.test.ts", "**/*.spec.tsx", "**/*.test.tsx", "jest.config.ts"], - "include": [ - "**/*.js", - "**/*.jsx", - "**/*.ts", - "**/*.tsx", - "../../custom-typings/index.d.ts", - "../../libs/shared/ui-core/src/state-management/app-state.ts", - "../../libs/shared/ui-core/src/jetstream-events.ts", - "../../libs/shared/ui-core/src/analytics.tsx" - ], + "include": ["**/*.js", "**/*.jsx", "**/*.ts", "**/*.tsx", "../../custom-typings/index.d.ts"], "files": ["../../node_modules/@nx/react/typings/cssmodule.d.ts", "../../node_modules/@nx/react/typings/image.d.ts"] } diff --git a/apps/jetstream/tsconfig.json b/apps/jetstream/tsconfig.json index bd6e09c93..cd7457292 100644 --- a/apps/jetstream/tsconfig.json +++ b/apps/jetstream/tsconfig.json @@ -20,12 +20,7 @@ "useDefineForClassFields": true }, "files": [], - "include": [ - "src", - "../../libs/shared/ui-core/src/state-management/app-state.ts", - "../../libs/shared/ui-core/src/jetstream-events.ts", - "../../libs/shared/ui-core/src/analytics.tsx" - ], + "include": ["src"], "exclude": ["**/*.js/*.worker.ts"], "references": [ { diff --git a/libs/features/deploy/src/DeployMetadata.tsx b/libs/features/deploy/src/DeployMetadata.tsx index ad5354911..bb35dd3c7 100644 --- a/libs/features/deploy/src/DeployMetadata.tsx +++ b/libs/features/deploy/src/DeployMetadata.tsx @@ -1,11 +1,10 @@ import { TITLES } from '@jetstream/shared/constants'; import { useTitle } from '@jetstream/shared/ui-utils'; import { SalesforceOrgUi } from '@jetstream/types'; -import { StateDebugObserver, selectedOrgState } from '@jetstream/ui-core'; +import { StateDebugObserver, fromDeployMetadataState, selectedOrgState } from '@jetstream/ui-core'; import { Fragment, FunctionComponent, useEffect, useState } from 'react'; import { Navigate, Outlet, useLocation } from 'react-router-dom'; import { useRecoilValue, useResetRecoilState } from 'recoil'; -import * as fromDeployMetadataState from './deploy-metadata.state'; // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface DeployMetadataProps {} diff --git a/libs/features/deploy/src/DeployMetadataDeployment.tsx b/libs/features/deploy/src/DeployMetadataDeployment.tsx index ae51095e9..734c22879 100644 --- a/libs/features/deploy/src/DeployMetadataDeployment.tsx +++ b/libs/features/deploy/src/DeployMetadataDeployment.tsx @@ -1,7 +1,7 @@ import { css } from '@emotion/react'; import { ListMetadataResultItem, useListMetadata } from '@jetstream/connected-ui'; import { ANALYTICS_KEYS } from '@jetstream/shared/constants'; -import { copyRecordsToClipboard, formatNumber } from '@jetstream/shared/ui-utils'; +import { copyRecordsToClipboard, formatNumber, isChromeExtension } from '@jetstream/shared/ui-utils'; import { pluralizeIfMultiple } from '@jetstream/shared/utils'; import { DeployMetadataTableRow, ListMetadataResult, SalesforceOrgUi, SidePanelType } from '@jetstream/types'; import { @@ -17,7 +17,7 @@ import { ToolbarItemActions, ToolbarItemGroup, } from '@jetstream/ui'; -import { applicationCookieState, fromJetstreamEvents, selectedOrgState, useAmplitude } from '@jetstream/ui-core'; +import { applicationCookieState, fromDeployMetadataState, fromJetstreamEvents, selectedOrgState, useAmplitude } from '@jetstream/ui-core'; import { addMinutes } from 'date-fns/addMinutes'; import { formatISO as formatISODate } from 'date-fns/formatISO'; import { isAfter } from 'date-fns/isAfter'; @@ -33,7 +33,6 @@ import AddToChangeset from './add-to-changeset/AddToChangeset'; import DeleteMetadataModal from './delete-metadata/DeleteMetadataModal'; import DeployMetadataHistoryModal from './deploy-metadata-history/DeployMetadataHistoryModal'; import DeployMetadataPackage from './deploy-metadata-package/DeployMetadataPackage'; -import * as fromDeployMetadataState from './deploy-metadata.state'; import DeployMetadataToOrg from './deploy-to-different-org/DeployMetadataToOrg'; import DeployMetadataSelectedItemsBadge from './utils/DeployMetadataSelectedItemsBadge'; import DownloadPackageWithFileSelector from './utils/DownloadPackageWithFileSelector'; @@ -89,6 +88,8 @@ export const DeployMetadataDeployment: FunctionComponent isChromeExtension()); + const listMetadataFilterFn = useCallback( (item: ListMetadataResult) => { const selectedUserSet = new Set(selectedUsers); @@ -305,7 +306,7 @@ export const DeployMetadataDeployment: FunctionComponent - + {!chromeExtension && } { + const [chromeExtension] = useState(() => isChromeExtension()); const [visibleRows, setVisibleRows] = useState(rows); const [globalFilter, setGlobalFilter] = useState(null); const [selectedRowIds, setSelectedRowIds] = useState(new Set()); @@ -47,7 +48,7 @@ export const DeployMetadataDeploymentTable: FunctionComponent diff --git a/libs/features/deploy/src/DeployMetadataSelection.tsx b/libs/features/deploy/src/DeployMetadataSelection.tsx index 8a9d6e16a..f62eac746 100644 --- a/libs/features/deploy/src/DeployMetadataSelection.tsx +++ b/libs/features/deploy/src/DeployMetadataSelection.tsx @@ -12,14 +12,13 @@ import { PageHeaderRow, PageHeaderTitle, } from '@jetstream/ui'; -import { RequireMetadataApiBanner, selectedOrgState, useAmplitude } from '@jetstream/ui-core'; +import { fromDeployMetadataState, RequireMetadataApiBanner, selectedOrgState, useAmplitude } from '@jetstream/ui-core'; import { FunctionComponent } from 'react'; import { Link } from 'react-router-dom'; import { useRecoilValue } from 'recoil'; import './DeployMetadataSelection.scss'; import DeployMetadataHistoryModal from './deploy-metadata-history/DeployMetadataHistoryModal'; import DeployMetadataPackage from './deploy-metadata-package/DeployMetadataPackage'; -import * as fromDeployMetadataState from './deploy-metadata.state'; import DownloadMetadataPackage from './download-metadata-package/DownloadMetadataPackage'; import DateSelection from './selection-components/DateSelection'; import ManagedPackageSelection from './selection-components/ManagedPackageSelection'; diff --git a/libs/features/deploy/src/add-to-changeset/AddToChangeset.tsx b/libs/features/deploy/src/add-to-changeset/AddToChangeset.tsx index a67f96522..8b8dd4981 100644 --- a/libs/features/deploy/src/add-to-changeset/AddToChangeset.tsx +++ b/libs/features/deploy/src/add-to-changeset/AddToChangeset.tsx @@ -1,11 +1,10 @@ import { ANALYTICS_KEYS } from '@jetstream/shared/constants'; import { ChangeSet, DeployMetadataTableRow, DeployResult, ListMetadataResult, Maybe, SalesforceOrgUi } from '@jetstream/types'; import { FileDownloadModal, Icon } from '@jetstream/ui'; -import { applicationCookieState, fromJetstreamEvents, useAmplitude } from '@jetstream/ui-core'; +import { applicationCookieState, fromDeployMetadataState, fromJetstreamEvents, useAmplitude } from '@jetstream/ui-core'; import classNames from 'classnames'; import { Fragment, FunctionComponent, useState } from 'react'; import { useRecoilState } from 'recoil'; -import * as fromDeployMetadataState from '../deploy-metadata.state'; import { convertRowsToMapOfListMetadataResults, getDeployResultsExcelData } from '../utils/deploy-metadata.utils'; import AddToChangesetConfigModal from './AddToChangesetConfigModal'; import AddToChangesetStatusModal from './AddToChangesetStatusModal'; diff --git a/libs/features/deploy/src/deploy-metadata.state.ts b/libs/features/deploy/src/deploy-metadata.state.ts deleted file mode 100644 index 77a1f63b2..000000000 --- a/libs/features/deploy/src/deploy-metadata.state.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { COMMON_METADATA_TYPES, ListMetadataQueryExtended } from '@jetstream/connected-ui'; -import { AllUser, ChangeSet, CommonUser, ListItem, MetadataObject, SalesforceUser, YesNo } from '@jetstream/types'; -import { isAfter } from 'date-fns/isAfter'; -import { isSameDay } from 'date-fns/isSameDay'; -import { atom, selector } from 'recoil'; - -export const metadataItemsState = atom({ - key: 'deploy-metadata.metadataItemsState', - default: null, -}); - -export const metadataItemsMapState = atom>({ - key: 'deploy-metadata.metadataItemsMapState', - default: {}, -}); - -export const selectedMetadataItemsState = atom>({ - key: 'deploy-metadata.selectedMetadataItemsState', - default: new Set(), -}); - -export const usersList = atom[] | null>({ - key: 'deploy-metadata.usersList', - default: null, -}); - -export const metadataSelectionTypeState = atom({ - key: 'deploy-metadata.metadataSelectionTypeState', - default: 'user', -}); - -export const userSelectionState = atom({ - key: 'deploy-metadata.userSelectionState', - default: 'all', -}); - -export const dateRangeSelectionState = atom({ - key: 'deploy-metadata.dateRangeSelectionState', - default: 'all', -}); - -export const includeManagedPackageItems = atom({ - key: 'deploy-metadata.includeManagedPackageItems', - default: 'No', -}); - -export const dateRangeStartState = atom({ - key: 'deploy-metadata.dateRangeStartState', - default: null, -}); - -export const dateRangeEndState = atom({ - key: 'deploy-metadata.dateRangeEndState', - default: null, -}); - -export const selectedUsersState = atom({ - key: 'deploy-metadata.selectedUsersState', - default: [], -}); - -export const changesetPackage = atom({ - key: 'deploy-metadata.changesetPackage', - default: '', -}); - -export const changesetPackages = atom[] | null>({ - key: 'deploy-metadata.changesetPackages', - default: null, -}); - -export const hasSelectionsMadeSelector = selector({ - key: 'deploy-metadata.hasSelectionsMadeSelector', - get: ({ get }) => { - const metadataSelectionType = get(metadataSelectionTypeState); - const userSelection = get(userSelectionState); - const dateRangeSelection = get(dateRangeSelectionState); - const dateStartRange = get(dateRangeStartState); - const dateEndRange = get(dateRangeEndState); - const selectedMetadataItems = get(selectedMetadataItemsState); - const selectedUsers = get(selectedUsersState); - - if (metadataSelectionType === 'user' && selectedMetadataItems.size === 0) { - return false; - } else if (userSelection === 'user' && selectedUsers.length === 0) { - return false; - } else if (dateRangeSelection === 'user' && !dateStartRange && !dateEndRange) { - return false; - } else if ( - dateRangeSelection === 'user' && - dateStartRange && - dateEndRange && - (isSameDay(dateStartRange, dateEndRange) || isAfter(dateStartRange, dateEndRange)) - ) { - return false; - } - return true; - }, -}); - -export const hasSelectionsMadeMessageSelector = selector({ - key: 'deploy-metadata.hasSelectionsMadeMessageSelector', - get: ({ get }) => { - const metadataSelectionType = get(metadataSelectionTypeState); - const userSelection = get(userSelectionState); - const dateRangeSelection = get(dateRangeSelectionState); - const dateStartRange = get(dateRangeStartState); - const dateEndRange = get(dateRangeEndState); - const selectedMetadataItems = get(selectedMetadataItemsState); - const selectedUsers = get(selectedUsersState); - - if (metadataSelectionType === 'user' && selectedMetadataItems.size === 0) { - return 'Choose one or more metadata types'; - } else if (userSelection === 'user' && selectedUsers.length === 0) { - return 'Choose one or more users or select All Users'; - } else if (dateRangeSelection === 'user' && !dateStartRange && !dateEndRange) { - return 'Choose a last modified start and/or end date or select Any Date'; - } else if ( - dateRangeSelection === 'user' && - dateStartRange && - dateEndRange && - (isSameDay(dateStartRange, dateEndRange) || isAfter(dateStartRange, dateEndRange)) - ) { - return 'The start date must be before the end date'; - } else { - return 'Continue to select the metadata components to deploy'; - } - }, -}); - -export const listMetadataQueriesSelector = selector({ - key: 'deploy-metadata.listMetadataQueriesSelector', - get: ({ get }) => { - const metadataSelectionType = get(metadataSelectionTypeState); - const metadataItemsMap = get(metadataItemsMapState); - const selectedMetadataItems = get(selectedMetadataItemsState); - return (metadataSelectionType === 'common' ? COMMON_METADATA_TYPES : Array.from(selectedMetadataItems)) - .filter((item) => metadataItemsMap[item]) - .map((item): ListMetadataQueryExtended => { - const metadataDescribe = metadataItemsMap[item]; - return { - type: metadataDescribe.xmlName, - folder: undefined, - inFolder: metadataDescribe.inFolder, - }; - }); - }, -}); - -export const amplitudeSubmissionSelector = selector({ - key: 'deploy-metadata.amplitudeSubmissionSelector', - get: ({ get }) => { - const metadataSelectionType = get(metadataSelectionTypeState); - const userSelection = get(userSelectionState); - const dateRangeSelection = get(dateRangeSelectionState); - const dateStartRange = get(dateRangeStartState); - const dateEndRange = get(dateRangeEndState); - const selectedMetadataItems = get(selectedMetadataItemsState); - const selectedUsers = get(selectedUsersState); - const includeManaged = get(includeManagedPackageItems); - - return { - metadataCount: selectedMetadataItems.size, - metadataSelectionType: metadataSelectionType, - userSelection: userSelection, - selectedUserCount: selectedUsers.length, - dateRangeSelection: dateRangeSelection, - dateStartRange: dateStartRange, - dateEndRange: dateEndRange, - includeManaged: includeManaged === 'Yes', - }; - }, -}); diff --git a/libs/features/deploy/src/selection-components/DateSelection.tsx b/libs/features/deploy/src/selection-components/DateSelection.tsx index fd11617de..8ab3b66e3 100644 --- a/libs/features/deploy/src/selection-components/DateSelection.tsx +++ b/libs/features/deploy/src/selection-components/DateSelection.tsx @@ -1,12 +1,12 @@ import { css } from '@emotion/react'; import { AllUser } from '@jetstream/types'; import { DatePicker, Grid } from '@jetstream/ui'; +import { fromDeployMetadataState } from '@jetstream/ui-core'; import { addDays } from 'date-fns/addDays'; import { isAfter } from 'date-fns/isAfter'; import { isSameDay } from 'date-fns/isSameDay'; import { Fragment, FunctionComponent, useEffect, useState } from 'react'; import { useRecoilState } from 'recoil'; -import * as fromDeployMetadataState from '../deploy-metadata.state'; import { RadioButtonItem, RadioButtonSelection } from './RadioButtonSelection'; const DATE_RANGE_RADIO_BUTTONS: RadioButtonItem[] = [ diff --git a/libs/features/deploy/src/selection-components/ManagedPackageSelection.tsx b/libs/features/deploy/src/selection-components/ManagedPackageSelection.tsx index 5fc932216..374f3852c 100644 --- a/libs/features/deploy/src/selection-components/ManagedPackageSelection.tsx +++ b/libs/features/deploy/src/selection-components/ManagedPackageSelection.tsx @@ -1,8 +1,8 @@ import { YesNo } from '@jetstream/types'; import { Grid } from '@jetstream/ui'; +import { fromDeployMetadataState } from '@jetstream/ui-core'; import { Fragment, FunctionComponent, useEffect, useState } from 'react'; import { useRecoilState } from 'recoil'; -import * as fromDeployMetadataState from '../deploy-metadata.state'; import { RadioButtonItem, RadioButtonSelection } from './RadioButtonSelection'; const INCL_MANAGED_PACKAGE_RADIO_BUTTONS: RadioButtonItem[] = [ diff --git a/libs/features/deploy/src/selection-components/MetadataSelection.tsx b/libs/features/deploy/src/selection-components/MetadataSelection.tsx index 5742b2cbc..e66ed7ce3 100644 --- a/libs/features/deploy/src/selection-components/MetadataSelection.tsx +++ b/libs/features/deploy/src/selection-components/MetadataSelection.tsx @@ -2,10 +2,10 @@ import { COMMON_METADATA_TYPES, DescribeMetadataList, getMetadataLabelFromFullNa import { useNonInitialEffect } from '@jetstream/shared/ui-utils'; import { CommonUser, SalesforceOrgUi } from '@jetstream/types'; import { AutoFullHeightContainer, Grid, ReadonlyList } from '@jetstream/ui'; +import { fromDeployMetadataState } from '@jetstream/ui-core'; import isBoolean from 'lodash/isBoolean'; import { Fragment, FunctionComponent, useEffect, useState } from 'react'; import { useRecoilState } from 'recoil'; -import * as fromDeployMetadataState from '../deploy-metadata.state'; import { RadioButtonItem, RadioButtonSelection } from './RadioButtonSelection'; const METADATA_TYPES_RADIO_BUTTONS: RadioButtonItem[] = [ diff --git a/libs/features/deploy/src/selection-components/UserSelection.tsx b/libs/features/deploy/src/selection-components/UserSelection.tsx index f2e573434..942aa0283 100644 --- a/libs/features/deploy/src/selection-components/UserSelection.tsx +++ b/libs/features/deploy/src/selection-components/UserSelection.tsx @@ -2,10 +2,10 @@ import { useNonInitialEffect } from '@jetstream/shared/ui-utils'; import { NOOP } from '@jetstream/shared/utils'; import { AllUser, ListItem, SalesforceOrgUi } from '@jetstream/types'; import { Grid, List } from '@jetstream/ui'; +import { fromDeployMetadataState } from '@jetstream/ui-core'; import { FunctionComponent, useEffect, useState } from 'react'; import { useRecoilState } from 'recoil'; import DeployMetadataUserList from '../DeployMetadataUserList'; -import * as fromDeployMetadataState from '../deploy-metadata.state'; import { RadioButtonItem, RadioButtonSelection } from './RadioButtonSelection'; const USER_SELECTION_RADIO_BUTTONS: RadioButtonItem[] = [ diff --git a/libs/features/deploy/src/utils/useMetadataSelection.tsx b/libs/features/deploy/src/utils/useMetadataSelection.tsx index 9f51d6f20..cbd8e2a3f 100644 --- a/libs/features/deploy/src/utils/useMetadataSelection.tsx +++ b/libs/features/deploy/src/utils/useMetadataSelection.tsx @@ -1,7 +1,7 @@ import { CommonUser } from '@jetstream/types'; +import { fromDeployMetadataState } from '@jetstream/ui-core'; import isBoolean from 'lodash/isBoolean'; import { useRecoilState } from 'recoil'; -import * as fromDeployMetadataState from '../deploy-metadata.state'; export function useMetadataSelection() { const [metadataSelectionType, setMetadataSelectionType] = useRecoilState(fromDeployMetadataState.metadataSelectionTypeState); diff --git a/libs/features/deploy/src/view-or-compare-metadata/ViewOrCompareMetadataModal.tsx b/libs/features/deploy/src/view-or-compare-metadata/ViewOrCompareMetadataModal.tsx index 1daa608bb..f15467ac5 100644 --- a/libs/features/deploy/src/view-or-compare-metadata/ViewOrCompareMetadataModal.tsx +++ b/libs/features/deploy/src/view-or-compare-metadata/ViewOrCompareMetadataModal.tsx @@ -1,6 +1,6 @@ import { css } from '@emotion/react'; import { logger } from '@jetstream/shared/client-logger'; -import { useNonInitialEffect } from '@jetstream/shared/ui-utils'; +import { isChromeExtension, useNonInitialEffect } from '@jetstream/shared/ui-utils'; import { unSanitizeXml } from '@jetstream/shared/utils'; import { SplitWrapper as Split } from '@jetstream/splitjs'; import { FileExtAllTypes, ListMetadataResult, SalesforceOrgUi } from '@jetstream/types'; @@ -28,6 +28,7 @@ export const ViewOrCompareMetadataModal: FunctionComponent { + const [chromeExtension] = useState(() => isChromeExtension()); const [{ google_apiKey, google_appId, google_clientId }] = useRecoilState(applicationCookieState); const editorRef = useRef(); const diffEditorRef = useRef(); @@ -228,7 +229,7 @@ export const ViewOrCompareMetadataModal: FunctionComponent void; onDownloadPackage: (which: OrgType) => void; onExportSummary: () => void; @@ -24,6 +25,7 @@ export const ViewOrCompareMetadataModalFooter: FunctionComponent - + {!isChromeExtension && ( + <> + - + + + )} + + } + > +
+
+ Notification Example +
+ +

+ Jetstream can let you know when long running processes finish and your browser tab is not focused, such as if you start a data + load and then go check your email. +

+ +

+ When notifications are enabled, they will only show up if tasks finish in the background and you will never get a notification + unless you are actively using Jetstream. +

+ + Here are some examples of notifications Jetstream will provide: +
    +
  • Your data load is finished
  • +
  • Your deployment is finished
  • +
  • Your query results are ready to view
  • +
  • Your anonymous apex has finished executing
  • +
+
+ + )} + + ); +}; + +export default NotificationsRequestModal; diff --git a/apps/jetstream/src/app/components/core/app-routes.ts b/libs/shared/ui-core/src/app/app-routes.ts similarity index 100% rename from apps/jetstream/src/app/components/core/app-routes.ts rename to libs/shared/ui-core/src/app/app-routes.ts diff --git a/libs/shared/ui-core/src/app/jetstream-logo-v1-200w.png b/libs/shared/ui-core/src/app/jetstream-logo-v1-200w.png new file mode 100644 index 000000000..0887d4045 Binary files /dev/null and b/libs/shared/ui-core/src/app/jetstream-logo-v1-200w.png differ diff --git a/libs/shared/ui-core/src/app/jetstream-sample-notification.png b/libs/shared/ui-core/src/app/jetstream-sample-notification.png new file mode 100644 index 000000000..24ecc962f Binary files /dev/null and b/libs/shared/ui-core/src/app/jetstream-sample-notification.png differ diff --git a/libs/shared/ui-core/src/icons/JetstreamIcon.tsx b/libs/shared/ui-core/src/icons/JetstreamIcon.tsx new file mode 100644 index 000000000..8b4d1b425 --- /dev/null +++ b/libs/shared/ui-core/src/icons/JetstreamIcon.tsx @@ -0,0 +1,72 @@ +import { css } from '@emotion/react'; +import React, { FunctionComponent } from 'react'; + +export const JetstreamIcon: FunctionComponent> = (props) => ( + + + + + + + + + + + + + + + + + + + + + + + + +); + +export default JetstreamIcon; diff --git a/libs/shared/ui-core/src/icons/JetstreamLogo.tsx b/libs/shared/ui-core/src/icons/JetstreamLogo.tsx new file mode 100644 index 000000000..a3797ed58 --- /dev/null +++ b/libs/shared/ui-core/src/icons/JetstreamLogo.tsx @@ -0,0 +1,25 @@ +import React, { FunctionComponent } from 'react'; + +export const JetstreamLogo: FunctionComponent> = (props) => ( + + + + + + + + + + + + + + + + + + + +); + +export default JetstreamLogo; diff --git a/libs/shared/ui-core/src/icons/JetstreamLogoInverse.tsx b/libs/shared/ui-core/src/icons/JetstreamLogoInverse.tsx new file mode 100644 index 000000000..4e32cf8a2 --- /dev/null +++ b/libs/shared/ui-core/src/icons/JetstreamLogoInverse.tsx @@ -0,0 +1,64 @@ +import React, { FunctionComponent } from 'react'; + +export const JetstreamLogoInverse: FunctionComponent> = (props) => ( + + + + + + + + + + + + + + + + + + +); + +export default JetstreamLogoInverse; diff --git a/libs/shared/ui-core/src/index.ts b/libs/shared/ui-core/src/index.ts index 46914248e..ce41802bc 100644 --- a/libs/shared/ui-core/src/index.ts +++ b/libs/shared/ui-core/src/index.ts @@ -1,11 +1,15 @@ -export { StateDebugObserver } from './StateDebugObserver'; export * from './analytics'; +export * from './app/app-routes'; +export * from './app/AppHome'; +export * from './app/AppLoading'; export * from './app/ConfirmPageChange'; export * from './app/DownloadFileStream'; export * from './app/EmailSupport'; export * from './app/ErrorBoundaryFallback'; export * from './app/HeaderDonatePopover'; export * from './app/HeaderHelpPopover'; +export * from './app/HeaderNavbar'; +export * from './app/NotificationsRequestModal'; export * from './app/PromptNavigation'; export * from './app/RequireMetadataApiBanner'; export * from './create-fields/create-fields-types'; @@ -17,20 +21,26 @@ export * from './deploy/DeployMetadataResultsSuccessTable'; export * from './deploy/DeployMetadataResultsTables'; export * from './deploy/DeployMetadataUnitTestCodeCoverageResultsTable'; export * from './deploy/DeployMetadataUnitTestFailuresTable'; -export * from './formula-evaluator/FormulaEvaluatorRecordSearch'; -export * from './formula-evaluator/FormulaEvaluatorResults'; -export * from './formula-evaluator/FormulaEvaluatorUserSearch'; export * from './formula-evaluator/formula-evaluator.editor-utils'; export * from './formula-evaluator/formula-evaluator.types'; export * from './formula-evaluator/formula-evaluator.utils'; +export * from './formula-evaluator/FormulaEvaluatorRecordSearch'; +export * from './formula-evaluator/FormulaEvaluatorResults'; +export * from './formula-evaluator/FormulaEvaluatorUserSearch'; +export * from './icons/JetstreamIcon'; +export * from './icons/JetstreamLogo'; +export * from './icons/JetstreamLogoInverse'; export * from './jetstream-events'; export * from './jobs/Job'; export * from './jobs/Jobs'; +export * from './jobs/JobWorker'; export * from './load-records-results/LoadRecordsBulkApiResultsTable'; export * from './load-records-results/LoadRecordsBulkApiResultsTableRow'; export * from './load-records-results/LoadRecordsResultsTableProcessingErrRow'; -export * from './load/LoadRecordsResultsModal'; export * from './load/load-records-utils'; +export * from './load/LoadRecordsResultsModal'; +export * from './mass-update-records/mass-update-records.types'; +export * from './mass-update-records/mass-update-records.utils'; export * from './mass-update-records/MassUpdateRecordObjectHeading'; export * from './mass-update-records/MassUpdateRecordsDeploymentRow'; export * from './mass-update-records/MassUpdateRecordsObjectRow'; @@ -38,12 +48,16 @@ export * from './mass-update-records/MassUpdateRecordsObjectRowCriteria'; export * from './mass-update-records/MassUpdateRecordsObjectRowField'; export * from './mass-update-records/MassUpdateRecordsObjectRowValue'; export * from './mass-update-records/MassUpdateRecordsObjectRowValueStaticInput'; -export * from './mass-update-records/mass-update-records.types'; -export * from './mass-update-records/mass-update-records.utils'; export * from './mass-update-records/useDeployRecords'; export * from './metadata/useDeployMetadataPackage'; +export * from './orgs/AddOrg'; +export * from './orgs/OrgInfoPopover'; export * from './orgs/OrgLabelBadge'; +export * from './orgs/OrgPersistence'; export * from './orgs/OrgsCombobox'; +export * from './orgs/OrgsDropdown'; +export * from './orgs/OrgSelectionRequired'; +export * from './orgs/OrgWelcomeInstructions'; export * from './orgs/useOrgPermissions'; export * from './record/RecordSearchPopover'; export * from './record/ViewChildRecords'; @@ -57,3 +71,4 @@ export * as fromLoadRecordsState from './state-management/load-records.state'; export * as fromPermissionsState from './state-management/manage-permissions.state'; export * as fromQueryHistoryState from './state-management/query-history.state'; export * as fromQueryState from './state-management/query.state'; +export { StateDebugObserver } from './StateDebugObserver'; diff --git a/libs/shared/ui-core/src/jobs/JobWorker.ts b/libs/shared/ui-core/src/jobs/JobWorker.ts new file mode 100644 index 000000000..cfb44ddd8 --- /dev/null +++ b/libs/shared/ui-core/src/jobs/JobWorker.ts @@ -0,0 +1,321 @@ +/// +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { + base64ToArrayBuffer, + getOrgUrlParams, + pollBulkApiJobUntilDone, + pollRetrieveMetadataResultsUntilDone, + prepareCsvFile, + prepareExcelFile, +} from '@jetstream/shared/browser-worker-utils'; +import { logger } from '@jetstream/shared/client-logger'; +import { MIME_TYPES } from '@jetstream/shared/constants'; +import { + bulkApiAddBatchToJob, + bulkApiCreateJob, + queryMore, + retrieveMetadataFromListMetadata, + retrieveMetadataFromManifestFile, + retrieveMetadataFromPackagesNames, + sobjectOperation, +} from '@jetstream/shared/data'; +import { + ensureArray, + flattenRecords, + getErrorMessage, + getIdFromRecordUrl, + getMapOfBaseAndSubqueryRecords, + getSObjectFromRecordUrl, + replaceSubqueryQueryResultsWithRecords, + splitArrayToMaxSize, +} from '@jetstream/shared/utils'; +import type { + AsyncJobType, + AsyncJobWorkerMessagePayload, + AsyncJobWorkerMessageResponse, + BulkDownloadJob, + CancelJob, + RetrievePackageFromListMetadataJob, + RetrievePackageFromManifestJob, + RetrievePackageFromPackageNamesJob, + SalesforceRecord, + UploadToGoogleJob, + WorkerMessage, +} from '@jetstream/types'; +import clamp from 'lodash/clamp'; +import isString from 'lodash/isString'; + +/** + * This class mimics a web-worker based on what the application uses for these methods + */ +export class WorkerAdapter { + private jobWorker = new JobWorker((name: string, data: any, error?: any) => { + this.onmessage({ data: { name, data, error } as any }); + }); + + onmessage: (event: { data: WorkerMessage }) => void; + + postMessage = ({ data, name, error }: WorkerMessage) => { + this.jobWorker.handleMessage(name, data); + }; +} + +export class JobWorker { + // Updated if a job is attempted to be cancelled, the job will check this on each iteration and cancel if possible + cancelledJobIds = new Set(); + + private replyToMessage: (name: string, data: any, error?: any, transferrable?: any) => void; + + constructor(replyToMessage: (name: string, data: any, error?: any, transferrable?: any) => void) { + this.replyToMessage = replyToMessage; + } + + public async handleMessage(name: AsyncJobType, payloadData: AsyncJobWorkerMessagePayload, port?: MessagePort) { + const { org, job } = payloadData || {}; + switch (name) { + case 'CancelJob': { + const { job } = payloadData as AsyncJobWorkerMessagePayload; + this.cancelledJobIds.add(job.id); + break; + } + case 'BulkDelete': { + try { + // TODO: add validation to ensure that we have at least one record + // also, we are assuming that all records are same SObject + const MAX_DELETE_RECORDS = 200; + + let { records, batchSize } = job.meta as { records: SalesforceRecord[]; batchSize?: number }; + records = Array.isArray(records) ? records : [records]; + + batchSize = clamp(batchSize || MAX_DELETE_RECORDS, 1, 200); + + const sobject = getSObjectFromRecordUrl(records[0].attributes.url); + const allIds: string[] = records.map((record) => getIdFromRecordUrl(record.attributes.url)); + + const results: any[] = []; + for (const ids of splitArrayToMaxSize(allIds, batchSize)) { + try { + // TODO: add progress notification and allow cancellation + let tempResults = await sobjectOperation(org, sobject, 'delete', { ids }, { allOrNone: false }); + tempResults = ensureArray(tempResults); + tempResults.forEach((result) => results.push(result)); + } catch (ex) { + logger.error('There was a problem deleting these records'); + } + } + + const response: AsyncJobWorkerMessageResponse = { job, results }; + this.replyToMessage(name, response); + } catch (ex) { + const response: AsyncJobWorkerMessageResponse = { job }; + this.replyToMessage(name, response, getErrorMessage(ex)); + } + break; + } + case 'BulkDownload': { + try { + const { org, job } = payloadData as AsyncJobWorkerMessagePayload; + const { + serverUrl, + sObject, + soql, + isTooling, + fields, + records, + hasAllRecords, + includeDeletedRecords, + useBulkApi, + fileFormat, + fileName, + subqueryFields, + includeSubquery, + googleFolder, + } = job.meta; + let mimeType: string; + let fileData; + let downloadedRecords = records; + + if (!useBulkApi) { + // eslint-disable-next-line prefer-const + let { nextRecordsUrl, totalRecordCount } = job.meta; + let done = !nextRecordsUrl; + + while (!done && nextRecordsUrl && !hasAllRecords) { + // emit progress + const results = { + done: false, + progress: Math.floor((downloadedRecords.length / Math.max(totalRecordCount, records.length)) * 100), + }; + const response: AsyncJobWorkerMessageResponse = { job, lastActivityUpdate: true, results }; + this.replyToMessage(name, response); + + const { queryResults } = await queryMore(org, nextRecordsUrl, isTooling).then(replaceSubqueryQueryResultsWithRecords); + done = queryResults.done; + nextRecordsUrl = queryResults.nextRecordsUrl; + downloadedRecords = downloadedRecords.concat(queryResults.records); + if (this.cancelledJobIds.has(job.id)) { + throw new Error('Job cancelled'); + } + } + } else { + // Submit bulk query job and poll until results are ready + // Main Browser context will handle downloading the file as a link so it can be streamed + const jobInfo = await bulkApiCreateJob(org, { type: includeDeletedRecords ? 'QUERY_ALL' : 'QUERY', sObject }); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const jobId = jobInfo.id!; + const batchResult = await bulkApiAddBatchToJob(org, jobId, soql, true); + + const response: AsyncJobWorkerMessageResponse = { job, lastActivityUpdate: true }; + this.replyToMessage(name, response, undefined); + + const finalResults = await pollBulkApiJobUntilDone(org, jobInfo, 1, { + onChecked: () => { + const response: AsyncJobWorkerMessageResponse = { job, lastActivityUpdate: true }; + this.replyToMessage(name, response, undefined); + }, + }); + + if (finalResults.batches?.[0].state === 'Failed') { + throw new Error(finalResults.batches[0].stateMessage); + } + + const results = { + done: true, + progress: 100, + mimeType: MIME_TYPES.CSV, + useBulkApi: true, + results: `${serverUrl}/static/bulk/${jobId}/${batchResult.id}/file?${getOrgUrlParams(org, { + type: 'result', + isQuery: 'true', + fileName, + })}`, + fileName, + fileFormat, + googleFolder, + }; + this.replyToMessage(name, { job, results }); + return; + } + + switch (fileFormat) { + case 'xlsx': { + if (includeSubquery && subqueryFields) { + fileData = prepareExcelFile(getMapOfBaseAndSubqueryRecords(downloadedRecords, fields, subqueryFields)); + } else { + fileData = prepareExcelFile(flattenRecords(downloadedRecords, fields), fields); + } + mimeType = MIME_TYPES.XLSX; + break; + } + case 'csv': { + fileData = prepareCsvFile(flattenRecords(downloadedRecords, fields), fields); + mimeType = MIME_TYPES.CSV; + break; + } + case 'json': { + fileData = JSON.stringify(downloadedRecords, null, 2); + mimeType = MIME_TYPES.JSON; + break; + } + case 'gdrive': { + if (includeSubquery && subqueryFields) { + fileData = prepareExcelFile(getMapOfBaseAndSubqueryRecords(downloadedRecords, fields, subqueryFields)); + } else { + fileData = prepareExcelFile(flattenRecords(downloadedRecords, fields), fields); + } + mimeType = MIME_TYPES.GSHEET; + break; + } + default: + throw new Error('A valid file type type has not been selected'); + } + + const results = { done: true, progress: 100, fileData, mimeType: '', fileName, fileFormat, googleFolder }; + + const response: AsyncJobWorkerMessageResponse = { job, results }; + this.replyToMessage(name, response); + } catch (ex) { + const response: AsyncJobWorkerMessageResponse = { job }; + this.replyToMessage(name, response, getErrorMessage(ex)); + } + break; + } + case 'UploadToGoogle': { + // Message is passed through to jobs.tsx for upload + try { + const { job } = payloadData as AsyncJobWorkerMessagePayload; + const response: AsyncJobWorkerMessageResponse = { job, results: job.meta }; + this.replyToMessage(name, response); + } catch (ex) { + const response: AsyncJobWorkerMessageResponse = { job }; + this.replyToMessage(name, response, getErrorMessage(ex)); + } + break; + } + case 'RetrievePackageZip': { + try { + const { org, job } = payloadData as AsyncJobWorkerMessagePayload< + RetrievePackageFromListMetadataJob | RetrievePackageFromManifestJob | RetrievePackageFromPackageNamesJob + >; + const { fileName, fileFormat, mimeType, uploadToGoogle, googleFolder } = job.meta; + + let id: string; + switch (job.meta.type) { + case 'listMetadata': { + id = (await retrieveMetadataFromListMetadata(org, job.meta.listMetadataItems)).id; + break; + } + case 'packageManifest': { + id = (await retrieveMetadataFromManifestFile(org, job.meta.packageManifest)).id; + break; + } + case 'packageNames': { + id = (await retrieveMetadataFromPackagesNames(org, job.meta.packageNames)).id; + break; + } + default: { + const response: AsyncJobWorkerMessageResponse = { job }; + this.replyToMessage(name, response, 'An invalid metadata type was provided'); + return; + } + } + + if (this.cancelledJobIds.has(job.id)) { + throw new Error('Job cancelled'); + } + + const results = await pollRetrieveMetadataResultsUntilDone(org, id, { + isCancelled: () => { + return this.cancelledJobIds.has(job.id); + }, + onChecked: () => { + const response: AsyncJobWorkerMessageResponse = { job, lastActivityUpdate: true }; + this.replyToMessage(name, response, undefined); + }, + }); + + if (isString(results.zipFile)) { + const fileData = base64ToArrayBuffer(results.zipFile); + const response: AsyncJobWorkerMessageResponse = { + job, + results: { fileData, mimeType, fileName, fileFormat, uploadToGoogle, googleFolder }, + }; + this.replyToMessage(name, response, undefined, fileData); + } else { + const response: AsyncJobWorkerMessageResponse = { job }; + this.replyToMessage(name, response, 'No file was provided from Salesforce'); + } + } catch (ex) { + const response: AsyncJobWorkerMessageResponse = { job }; + this.replyToMessage(name, response, getErrorMessage(ex)); + if (this.cancelledJobIds.has(job.id)) { + this.cancelledJobIds.delete(job.id); + } + } + break; + } + default: + break; + } + } +} diff --git a/libs/shared/ui-core/src/jobs/Jobs.tsx b/libs/shared/ui-core/src/jobs/Jobs.tsx index 6ee7075c7..be2eb72d9 100644 --- a/libs/shared/ui-core/src/jobs/Jobs.tsx +++ b/libs/shared/ui-core/src/jobs/Jobs.tsx @@ -8,6 +8,7 @@ import { AsyncJob, AsyncJobNew, AsyncJobType, + AsyncJobWorkerMessagePayload, AsyncJobWorkerMessageResponse, ErrorResult, FileExtAllTypes, @@ -19,7 +20,7 @@ import { import { Icon, Popover, PopoverRef } from '@jetstream/ui'; import classNames from 'classnames'; import uniqueId from 'lodash/uniqueId'; -import { FunctionComponent, useEffect, useRef } from 'react'; +import { FunctionComponent, useCallback, useEffect, useRef } from 'react'; import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil'; import { filter } from 'rxjs/operators'; import { fromJetstreamEvents } from '../jetstream-events'; @@ -27,13 +28,20 @@ import { applicationCookieState } from '../state-management/app-state'; import Job from './Job'; import JobPlaceholder from './JobPlaceholder'; import { jobsState, jobsUnreadState, selectActiveJobCount, selectJobs } from './jobs.state'; +import { WorkerAdapter } from './JobWorker'; -let jobsWorker: Worker; - -export function setJobsWorker(worker: Worker) { - jobsWorker = worker; +export interface WorkerCompatibleShim { + postMessage: (message: WorkerMessage) => void; + onmessage?: (event: { data: WorkerMessage }) => void; } +const jobsWorker = new WorkerAdapter(); + +// Deprecated, no longer using web-worker for jobs as it wasn't needed and caused issues with web-extension +// export function setJobsWorker(worker: WorkerCompatibleShim) { +// jobsWorker = worker; +// } + export const Jobs: FunctionComponent = () => { const popoverRef = useRef(null); const buttonRef = useRef(null); @@ -76,175 +84,153 @@ export const Jobs: FunctionComponent = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [newJobsToProcess]); - useEffect(() => { - if (jobsWorker) { - jobsWorker.onmessage = (event: MessageEvent) => { - logger.info(event.data); - const { name, data, error } = event.data as WorkerMessage; - switch (name) { - case 'BulkDelete': { - try { - let newJob = { ...data.job }; - if (error) { - newJob = { - ...newJob, - finished: new Date(), - lastActivity: new Date(), - status: 'failed', - statusMessage: error || 'An unknown error ocurred', - }; - setJobs((prevJobs) => ({ ...prevJobs, [newJob.id]: newJob })); - notifyUser(`Delete records failed`, { body: newJob.statusMessage, tag: 'BulkDelete' }); - } else { - const results: RecordResult[] = Array.isArray(data.results) ? data.results : [data.results]; - const firstErrorRec = results.filter((record) => !record.success) as ErrorResult[]; + const handleEvent = useCallback(({ name, data, error }: WorkerMessage) => { + logger.info('[WORKER EVENT]', { name, data, error }); + switch (name) { + case 'BulkDelete': { + try { + let newJob = { ...data.job }; + if (error) { + newJob = { + ...newJob, + finished: new Date(), + lastActivity: new Date(), + status: 'failed', + statusMessage: error || 'An unknown error ocurred', + }; + setJobs((prevJobs) => ({ ...prevJobs, [newJob.id]: newJob })); + notifyUser(`Delete records failed`, { body: newJob.statusMessage, tag: 'BulkDelete' }); + } else { + const results: RecordResult[] = Array.isArray(data.results) ? data.results : [data.results]; + const firstErrorRec = results.filter((record) => !record.success) as ErrorResult[]; - newJob = { - ...newJob, - results, - finished: new Date(), - lastActivity: new Date(), - status: firstErrorRec.length ? 'failed' : 'success', - statusMessage: firstErrorRec.length - ? `${firstErrorRec.length} ${pluralizeIfMultiple('Error', firstErrorRec)} - ${ - firstErrorRec[0]?.errors[0]?.message || 'An unknown error ocurred' - }` - : `${results.length.toLocaleString()} ${pluralizeIfMultiple('record', results)} deleted successfully`, - }; - notifyUser(`Delete records finished`, { body: newJob.statusMessage, tag: 'BulkDelete' }); - } - setJobs((prevJobs) => ({ ...prevJobs, [newJob.id]: newJob })); - } catch (ex) { - // TODO: - logger.error('[ERROR][JOB] Error processing job results', ex); - } - break; + newJob = { + ...newJob, + results, + finished: new Date(), + lastActivity: new Date(), + status: firstErrorRec.length ? 'failed' : 'success', + statusMessage: firstErrorRec.length + ? `${firstErrorRec.length} ${pluralizeIfMultiple('Error', firstErrorRec)} - ${ + firstErrorRec[0]?.errors[0]?.message || 'An unknown error ocurred' + }` + : `${results.length.toLocaleString()} ${pluralizeIfMultiple('record', results)} deleted successfully`, + }; + notifyUser(`Delete records finished`, { body: newJob.statusMessage, tag: 'BulkDelete' }); } - case 'BulkDownload': { - try { - let newJob = { ...data.job }; - if (error) { - newJob = { - ...newJob, - finished: new Date(), - lastActivity: new Date(), - status: 'failed', - statusMessage: error || 'An unknown error ocurred', - }; - setJobs((prevJobs) => ({ ...prevJobs, [newJob.id]: newJob })); - notifyUser(`Download records failed`, { body: newJob.statusMessage, tag: 'BulkDownload' }); - } else if (data.lastActivityUpdate) { - newJob = { - ...newJob, - lastActivity: new Date(), - }; - setJobs((prevJobs) => ({ ...prevJobs, [newJob.id]: newJob })); - } else { - const { done, progress, fileData, useBulkApi, fileFormat, results, googleFolder } = data.results as { - done: boolean; - progress: number; - fileData: any; - useBulkApi?: boolean; - mimeType: MimeType; - fileFormat: string; - results?: string; - googleFolder?: string; - }; - let { fileName, mimeType } = data.results as { fileName: string; mimeType: MimeType }; + setJobs((prevJobs) => ({ ...prevJobs, [newJob.id]: newJob })); + } catch (ex) { + // TODO: + logger.error('[ERROR][JOB] Error processing job results', ex); + } + break; + } + case 'BulkDownload': { + try { + let newJob = { ...data.job }; + if (error) { + newJob = { + ...newJob, + finished: new Date(), + lastActivity: new Date(), + status: 'failed', + statusMessage: error || 'An unknown error ocurred', + }; + setJobs((prevJobs) => ({ ...prevJobs, [newJob.id]: newJob })); + notifyUser(`Download records failed`, { body: newJob.statusMessage, tag: 'BulkDownload' }); + } else if (data.lastActivityUpdate) { + newJob = { + ...newJob, + lastActivity: new Date(), + }; + setJobs((prevJobs) => ({ ...prevJobs, [newJob.id]: newJob })); + } else { + const { done, progress, fileData, useBulkApi, fileFormat, results, googleFolder } = data.results as { + done: boolean; + progress: number; + fileData: any; + useBulkApi?: boolean; + mimeType: MimeType; + fileFormat: string; + results?: string; + googleFolder?: string; + }; + let { fileName, mimeType } = data.results as { fileName: string; mimeType: MimeType }; - if (!done) { - newJob = { - ...newJob, - lastActivity: new Date(), - status: 'in-progress', - statusMessage: `Download in progress ${progress}%`, - }; - setJobs((prevJobs) => ({ ...prevJobs, [newJob.id]: newJob })); + if (!done) { + newJob = { + ...newJob, + lastActivity: new Date(), + status: 'in-progress', + statusMessage: `Download in progress ${progress}%`, + }; + setJobs((prevJobs) => ({ ...prevJobs, [newJob.id]: newJob })); + } else { + newJob = { + ...newJob, + finished: new Date(), + lastActivity: new Date(), + status: 'success', + statusMessage: 'Records downloaded successfully', + }; + if (useBulkApi) { + if (results) { + fromJetstreamEvents.emit({ + type: 'streamFileDownload', + payload: { + fileName, + link: results, + }, + }); + setJobs((prevJobs) => ({ + ...prevJobs, + [newJob.id]: { + ...newJob, + results, + finished: new Date(), + lastActivity: new Date(), + }, + })); } else { - newJob = { + // TODO: handle error + } + } else if (fileFormat === 'gdrive' && gapi?.client?.getToken()?.access_token) { + // show status of uploading to Google + setJobs((prevJobs) => ({ + ...prevJobs, + [newJob.id]: { ...newJob, - finished: new Date(), lastActivity: new Date(), - status: 'success', - statusMessage: 'Records downloaded successfully', - }; - if (useBulkApi) { - if (results) { - fromJetstreamEvents.emit({ - type: 'streamFileDownload', - payload: { - fileName, - link: results, - }, - }); - setJobs((prevJobs) => ({ - ...prevJobs, - [newJob.id]: { - ...newJob, - results, - finished: new Date(), - lastActivity: new Date(), - }, - })); - } else { - // TODO: handle error - } - } else if (fileFormat === 'gdrive' && gapi?.client?.getToken()?.access_token) { - // show status of uploading to Google - setJobs((prevJobs) => ({ - ...prevJobs, - [newJob.id]: { - ...newJob, - lastActivity: new Date(), - status: 'in-progress', - statusMessage: 'Uploading file to Google', - }, - })); + status: 'in-progress', + statusMessage: 'Uploading file to Google', + }, + })); - newJob = { - ...newJob, - status: 'success', - statusMessage: 'Records downloaded and saved to Google successfully', - }; + newJob = { + ...newJob, + status: 'success', + statusMessage: 'Records downloaded and saved to Google successfully', + }; - googleUploadFile(gapi.client.getToken().access_token, { - fileMimeType: MIME_TYPES.XLSX_OPEN_OFFICE, - filename: fileName, - folderId: googleFolder, - fileData, - }) - .then(({ id, webViewLink }) => { - newJob.results = webViewLink; - }) - .catch((err) => { - // Failed to upload to google, save locally - newJob.statusMessage = 'Records downloaded and saved to computer, saving to Google failed.'; - newJob.status = 'finished-warning'; - mimeType = MIME_TYPES.XLSX; - saveFile(fileData, `${fileName}.xlsx`, mimeType); - notifyUser(newJob.statusMessage, { tag: 'BulkDownload' }); - rollbar.error('Error saving to Google Drive', { err, message: err?.message }); - }) - .finally(() => { - setJobs((prevJobs) => ({ - ...prevJobs, - [newJob.id]: { - ...newJob, - finished: new Date(), - lastActivity: new Date(), - }, - })); - }); - } else { - if (fileFormat === 'gdrive') { - // Failed to upload to google, save locally - mimeType = MIME_TYPES.XLSX; - fileName = `${fileName}.xlsx`; - newJob.statusMessage = 'Records downloaded and saved to computer, saving to Google failed.'; - newJob.status = 'finished-warning'; - } - saveFile(fileData, fileName, mimeType); - notifyUser(`Download records finished`, { tag: 'BulkDownload' }); + googleUploadFile(gapi.client.getToken().access_token, { + fileMimeType: MIME_TYPES.XLSX_OPEN_OFFICE, + filename: fileName, + folderId: googleFolder, + fileData, + }) + .then(({ id, webViewLink }) => { + newJob.results = webViewLink; + }) + .catch((err) => { + // Failed to upload to google, save locally + newJob.statusMessage = 'Records downloaded and saved to computer, saving to Google failed.'; + newJob.status = 'finished-warning'; + mimeType = MIME_TYPES.XLSX; + saveFile(fileData, `${fileName}.xlsx`, mimeType); + notifyUser(newJob.statusMessage, { tag: 'BulkDownload' }); + rollbar.error('Error saving to Google Drive', { err, message: err?.message }); + }) + .finally(() => { setJobs((prevJobs) => ({ ...prevJobs, [newJob.id]: { @@ -253,20 +239,141 @@ export const Jobs: FunctionComponent = () => { lastActivity: new Date(), }, })); - } + }); + } else { + if (fileFormat === 'gdrive') { + // Failed to upload to google, save locally + mimeType = MIME_TYPES.XLSX; + fileName = `${fileName}.xlsx`; + newJob.statusMessage = 'Records downloaded and saved to computer, saving to Google failed.'; + newJob.status = 'finished-warning'; } + saveFile(fileData, fileName, mimeType); + notifyUser(`Download records finished`, { tag: 'BulkDownload' }); + setJobs((prevJobs) => ({ + ...prevJobs, + [newJob.id]: { + ...newJob, + finished: new Date(), + lastActivity: new Date(), + }, + })); } - } catch (ex) { - // TODO: - logger.error('[ERROR][JOB] Error processing job results', ex); } - break; } - case 'UploadToGoogle': { - try { - // TODO: can we share code with bulk download? - let newJob = { ...data.job }; - const { fileName, fileData, fileType, googleFolder } = data.results as UploadToGoogleJob; + } catch (ex) { + // TODO: + logger.error('[ERROR][JOB] Error processing job results', ex); + } + break; + } + case 'UploadToGoogle': { + try { + // TODO: can we share code with bulk download? + let newJob = { ...data.job }; + const { fileName, fileData, fileType, googleFolder } = data.results as UploadToGoogleJob; + newJob = { + ...newJob, + lastActivity: new Date(), + status: 'in-progress', + statusMessage: 'Uploading file to Google', + }; + setJobs((prevJobs) => ({ ...prevJobs, [newJob.id]: newJob })); + + newJob = { + ...newJob, + status: 'success', + statusMessage: 'Records downloaded and saved to Google successfully', + }; + + if (gapi?.client?.getToken()?.access_token) { + googleUploadFile( + gapi.client.getToken().access_token, + { + fileMimeType: fileType === 'xlsx' ? MIME_TYPES.XLSX_OPEN_OFFICE : fileExtToMimeType[fileType].replace(';charset=utf-8', ''), + filename: fileName, + folderId: googleFolder, + fileData, + }, + fileExtToGoogleDriveMimeType[fileType] + ) + .then(({ id, webViewLink }) => { + newJob.results = webViewLink; + }) + .catch((err) => { + // Failed to upload to google, save locally + handleGoogleUploadFailure({ fileData, fileName, fileType }, newJob); + rollbar.error('Error saving to Google Drive', { err, message: err?.message }); + }) + .finally(() => { + setJobs((prevJobs) => ({ + ...prevJobs, + [newJob.id]: { + ...newJob, + finished: new Date(), + lastActivity: new Date(), + }, + })); + }); + } else { + handleGoogleUploadFailure({ fileData, fileName, fileType }, newJob); + setJobs((prevJobs) => ({ + ...prevJobs, + [newJob.id]: { + ...newJob, + finished: new Date(), + lastActivity: new Date(), + }, + })); + } + } catch (ex) { + // TODO: + logger.error('[ERROR][JOB] Error processing job results', ex); + } + break; + } + case 'RetrievePackageZip': { + try { + let newJob = { ...data.job }; + if (error) { + newJob = { + ...newJob, + finished: new Date(), + lastActivity: new Date(), + status: 'failed', + statusMessage: error || 'An unknown error ocurred', + }; + notifyUser(`Package download failed`, { body: newJob.statusMessage, tag: 'RetrievePackageZip' }); + setJobs((prevJobs) => ({ ...prevJobs, [newJob.id]: newJob })); + } else if (data.lastActivityUpdate) { + newJob = { + ...newJob, + lastActivity: new Date(), + }; + setJobs((prevJobs) => ({ ...prevJobs, [newJob.id]: newJob })); + } else { + const { fileData, mimeType, fileName, fileFormat, uploadToGoogle, googleFolder } = data.results as { + fileData: ArrayBuffer; + mimeType: MimeType; + fileName: string; + fileFormat: FileExtAllTypes; + uploadToGoogle: boolean; + googleFolder?: string; + }; + + if (!uploadToGoogle) { + newJob = { + ...newJob, + finished: new Date(), + lastActivity: new Date(), + status: 'success', + statusMessage: 'Package downloaded successfully', + }; + + saveFile(fileData, `${fileName}.${fileFormat}`, mimeType); + notifyUser(`Package download finished`, { tag: 'RetrievePackageZip' }); + setJobs((prevJobs) => ({ ...prevJobs, [newJob.id]: newJob })); + } else { newJob = { ...newJob, lastActivity: new Date(), @@ -278,27 +385,27 @@ export const Jobs: FunctionComponent = () => { newJob = { ...newJob, status: 'success', - statusMessage: 'Records downloaded and saved to Google successfully', + statusMessage: 'Package saved to Google successfully', }; if (gapi?.client?.getToken()?.access_token) { + const targetMimeType = (fileExtToGoogleDriveMimeType as any)[fileFormat]; googleUploadFile( gapi.client.getToken().access_token, { - fileMimeType: - fileType === 'xlsx' ? MIME_TYPES.XLSX_OPEN_OFFICE : fileExtToMimeType[fileType].replace(';charset=utf-8', ''), - filename: fileName, + fileMimeType: mimeType, + filename: targetMimeType === MIME_TYPES.GSHEET ? fileName : `${fileName}.${fileFormat}`, folderId: googleFolder, fileData, }, - fileExtToGoogleDriveMimeType[fileType] + targetMimeType ) .then(({ id, webViewLink }) => { newJob.results = webViewLink; }) .catch((err) => { // Failed to upload to google, save locally - handleGoogleUploadFailure({ fileData, fileName, fileType }, newJob); + handleGoogleUploadFailure({ fileData, fileName, fileType: 'zip' }, newJob); rollbar.error('Error saving to Google Drive', { err, message: err?.message }); }) .finally(() => { @@ -312,7 +419,7 @@ export const Jobs: FunctionComponent = () => { })); }); } else { - handleGoogleUploadFailure({ fileData, fileName, fileType }, newJob); + handleGoogleUploadFailure({ fileData, fileName, fileType: 'zip' }, newJob); setJobs((prevJobs) => ({ ...prevJobs, [newJob.id]: { @@ -322,129 +429,33 @@ export const Jobs: FunctionComponent = () => { }, })); } - } catch (ex) { - // TODO: - logger.error('[ERROR][JOB] Error processing job results', ex); } - break; } - case 'RetrievePackageZip': { - try { - let newJob = { ...data.job }; - if (error) { - newJob = { - ...newJob, - finished: new Date(), - lastActivity: new Date(), - status: 'failed', - statusMessage: error || 'An unknown error ocurred', - }; - notifyUser(`Package download failed`, { body: newJob.statusMessage, tag: 'RetrievePackageZip' }); - setJobs((prevJobs) => ({ ...prevJobs, [newJob.id]: newJob })); - } else if (data.lastActivityUpdate) { - newJob = { - ...newJob, - lastActivity: new Date(), - }; - setJobs((prevJobs) => ({ ...prevJobs, [newJob.id]: newJob })); - } else { - const { fileData, mimeType, fileName, fileFormat, uploadToGoogle, googleFolder } = data.results as { - fileData: ArrayBuffer; - mimeType: MimeType; - fileName: string; - fileFormat: FileExtAllTypes; - uploadToGoogle: boolean; - googleFolder?: string; - }; - - if (!uploadToGoogle) { - newJob = { - ...newJob, - finished: new Date(), - lastActivity: new Date(), - status: 'success', - statusMessage: 'Package downloaded successfully', - }; - - saveFile(fileData, `${fileName}.${fileFormat}`, mimeType); - notifyUser(`Package download finished`, { tag: 'RetrievePackageZip' }); - setJobs((prevJobs) => ({ ...prevJobs, [newJob.id]: newJob })); - } else { - newJob = { - ...newJob, - lastActivity: new Date(), - status: 'in-progress', - statusMessage: 'Uploading file to Google', - }; - setJobs((prevJobs) => ({ ...prevJobs, [newJob.id]: newJob })); - - newJob = { - ...newJob, - status: 'success', - statusMessage: 'Package saved to Google successfully', - }; - - if (gapi?.client?.getToken()?.access_token) { - const targetMimeType = (fileExtToGoogleDriveMimeType as any)[fileFormat]; - googleUploadFile( - gapi.client.getToken().access_token, - { - fileMimeType: mimeType, - filename: targetMimeType === MIME_TYPES.GSHEET ? fileName : `${fileName}.${fileFormat}`, - folderId: googleFolder, - fileData, - }, - targetMimeType - ) - .then(({ id, webViewLink }) => { - newJob.results = webViewLink; - }) - .catch((err) => { - // Failed to upload to google, save locally - handleGoogleUploadFailure({ fileData, fileName, fileType: 'zip' }, newJob); - rollbar.error('Error saving to Google Drive', { err, message: err?.message }); - }) - .finally(() => { - setJobs((prevJobs) => ({ - ...prevJobs, - [newJob.id]: { - ...newJob, - finished: new Date(), - lastActivity: new Date(), - }, - })); - }); - } else { - handleGoogleUploadFailure({ fileData, fileName, fileType: 'zip' }, newJob); - setJobs((prevJobs) => ({ - ...prevJobs, - [newJob.id]: { - ...newJob, - finished: new Date(), - lastActivity: new Date(), - }, - })); - } - } - } - } catch (ex) { - // TODO: - logger.error('[ERROR][JOB] Error processing job results', ex); - } - break; - } - default: - break; - } - if (data && data.lastActivityUpdate) { - fromJetstreamEvents.emit({ type: 'lastActivityUpdate', payload: data.job }); - } - if (data && !data.lastActivityUpdate && data.job) { - fromJetstreamEvents.emit({ type: 'jobFinished', payload: data.job }); + } catch (ex) { + // TODO: + logger.error('[ERROR][JOB] Error processing job results', ex); } + break; + } + default: + break; + } + if (data && data.lastActivityUpdate) { + fromJetstreamEvents.emit({ type: 'lastActivityUpdate', payload: data.job }); + } + if (data && !data.lastActivityUpdate && data.job) { + fromJetstreamEvents.emit({ type: 'jobFinished', payload: data.job }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + if (jobsWorker) { + jobsWorker.onmessage = (event: MessageEvent) => { + handleEvent(event.data as WorkerMessage); }; } - }, [jobsWorker, setJobs]); + }, [handleEvent, setJobs]); function handleGoogleUploadFailure( { diff --git a/libs/shared/ui-core/src/load-records-results/LoadRecordsBulkApiResultsTable.tsx b/libs/shared/ui-core/src/load-records-results/LoadRecordsBulkApiResultsTable.tsx index 67f69a220..e74d5f76e 100644 --- a/libs/shared/ui-core/src/load-records-results/LoadRecordsBulkApiResultsTable.tsx +++ b/libs/shared/ui-core/src/load-records-results/LoadRecordsBulkApiResultsTable.tsx @@ -1,7 +1,6 @@ import { css } from '@emotion/react'; -import { BulkJobBatchInfo, BulkJobWithBatches, Maybe } from '@jetstream/types'; +import { BulkJobBatchInfo, BulkJobWithBatches, DownloadAction, DownloadType, Maybe, PrepareDataResponseError } from '@jetstream/types'; import { FunctionComponent } from 'react'; -import { DownloadAction, DownloadType, PrepareDataResponseError } from '../../../../types/src/lib/ui/load-records-results-types'; import LoadRecordsBulkApiResultsTableRow from './LoadRecordsBulkApiResultsTableRow'; import LoadRecordsResultsTableProcessingErrRow from './LoadRecordsResultsTableProcessingErrRow'; export interface LoadRecordsBulkApiResultsTableProps { diff --git a/libs/shared/ui-core/src/load-records-results/LoadRecordsResultsTableProcessingErrRow.tsx b/libs/shared/ui-core/src/load-records-results/LoadRecordsResultsTableProcessingErrRow.tsx index 93832de56..63f4d00b0 100644 --- a/libs/shared/ui-core/src/load-records-results/LoadRecordsResultsTableProcessingErrRow.tsx +++ b/libs/shared/ui-core/src/load-records-results/LoadRecordsResultsTableProcessingErrRow.tsx @@ -1,8 +1,7 @@ import { formatNumber } from '@jetstream/shared/ui-utils'; -import { Maybe } from '@jetstream/types'; +import { Maybe, PrepareDataResponseError } from '@jetstream/types'; import { Icon } from '@jetstream/ui'; import { FunctionComponent, useEffect, useRef } from 'react'; -import { PrepareDataResponseError } from '../../../../types/src/lib/ui/load-records-results-types'; export interface LoadRecordsResultsTableProcessingErrRowProps { processingErrors: PrepareDataResponseError[]; diff --git a/libs/shared/ui-core/src/mass-update-records/MassUpdateRecordsDeploymentRow.tsx b/libs/shared/ui-core/src/mass-update-records/MassUpdateRecordsDeploymentRow.tsx index 22319673f..5f3393632 100644 --- a/libs/shared/ui-core/src/mass-update-records/MassUpdateRecordsDeploymentRow.tsx +++ b/libs/shared/ui-core/src/mass-update-records/MassUpdateRecordsDeploymentRow.tsx @@ -3,11 +3,10 @@ import { ANALYTICS_KEYS } from '@jetstream/shared/constants'; import { bulkApiGetRecords } from '@jetstream/shared/data'; import { formatNumber } from '@jetstream/shared/ui-utils'; import { decodeHtmlEntity, pluralizeFromNumber } from '@jetstream/shared/utils'; -import { BulkJobBatchInfo, BulkJobResultRecord, SalesforceOrgUi } from '@jetstream/types'; +import { BulkJobBatchInfo, BulkJobResultRecord, DownloadAction, DownloadType, SalesforceOrgUi } from '@jetstream/types'; import { Card, FileDownloadModal, Grid, SalesforceLogin, ScopedNotification, Spinner, SupportLink } from '@jetstream/ui'; import { Fragment, FunctionComponent, useEffect, useState } from 'react'; import { useRecoilValue } from 'recoil'; -import { DownloadAction, DownloadType } from '../../../../types/src/lib/ui/load-records-results-types'; import { useAmplitude } from '../analytics'; import { fromJetstreamEvents } from '../jetstream-events'; import LoadRecordsBulkApiResultsTable from '../load-records-results/LoadRecordsBulkApiResultsTable'; diff --git a/libs/shared/ui-core/src/mass-update-records/mass-update-records.types.tsx b/libs/shared/ui-core/src/mass-update-records/mass-update-records.types.tsx index aed374305..761bdc98a 100644 --- a/libs/shared/ui-core/src/mass-update-records/mass-update-records.types.tsx +++ b/libs/shared/ui-core/src/mass-update-records/mass-update-records.types.tsx @@ -1,5 +1,4 @@ -import { BulkJobWithBatches, DescribeSObjectResult, Field, ListItem, Maybe } from '@jetstream/types'; -import { PrepareDataResponseError } from '../../../../types/src/lib/ui/load-records-results-types'; +import { BulkJobWithBatches, DescribeSObjectResult, Field, ListItem, Maybe, PrepareDataResponseError } from '@jetstream/types'; export interface MetadataRow { /** Confirmed all input is valid, but does not indicate that the row has been validated */ diff --git a/apps/jetstream/src/app/components/orgs/AddOrg.tsx b/libs/shared/ui-core/src/orgs/AddOrg.tsx similarity index 98% rename from apps/jetstream/src/app/components/orgs/AddOrg.tsx rename to libs/shared/ui-core/src/orgs/AddOrg.tsx index fd9325d66..ec23b088f 100644 --- a/apps/jetstream/src/app/components/orgs/AddOrg.tsx +++ b/libs/shared/ui-core/src/orgs/AddOrg.tsx @@ -1,10 +1,10 @@ import { addOrg } from '@jetstream/shared/ui-utils'; import { SalesforceOrgUi } from '@jetstream/types'; import { Grid, GridCol, Icon, Input, Popover, PopoverRef, Radio, RadioGroup } from '@jetstream/ui'; -import { applicationCookieState } from '@jetstream/ui-core'; import classNames from 'classnames'; import { FunctionComponent, useEffect, useRef, useState } from 'react'; import { useRecoilState } from 'recoil'; +import { applicationCookieState } from '../state-management/app-state'; type OrgType = 'prod' | 'sandbox' | 'pre-release' | 'custom'; diff --git a/apps/jetstream/src/app/components/orgs/OrgInfoPopover.tsx b/libs/shared/ui-core/src/orgs/OrgInfoPopover.tsx similarity index 71% rename from apps/jetstream/src/app/components/orgs/OrgInfoPopover.tsx rename to libs/shared/ui-core/src/orgs/OrgInfoPopover.tsx index 94baed0a5..0ff495476 100644 --- a/apps/jetstream/src/app/components/orgs/OrgInfoPopover.tsx +++ b/libs/shared/ui-core/src/orgs/OrgInfoPopover.tsx @@ -14,11 +14,11 @@ import { SalesforceLogin, Spinner, } from '@jetstream/ui'; -import { applicationCookieState, selectSkipFrontdoorAuth } from '@jetstream/ui-core'; import classNames from 'classnames'; import startCase from 'lodash/startCase'; import { Fragment, FunctionComponent, ReactNode, useEffect, useState } from 'react'; import { useRecoilValue } from 'recoil'; +import { applicationCookieState, selectSkipFrontdoorAuth } from '../state-management/app-state'; const EMPTY_COLOR = '_none_'; @@ -44,9 +44,10 @@ export interface OrgInfoPopoverProps { org: SalesforceOrgUi; loading?: boolean; disableOrgActions?: boolean; - onAddOrg: (org: SalesforceOrgUi, switchActiveOrg: boolean) => void; - onRemoveOrg: (org: SalesforceOrgUi) => void; - onUpdateOrg: (org: SalesforceOrgUi, updatedOrg: Partial) => void; + isReadOnly?: boolean; + onAddOrg?: (org: SalesforceOrgUi, switchActiveOrg: boolean) => void; + onRemoveOrg?: (org: SalesforceOrgUi) => void; + onUpdateOrg?: (org: SalesforceOrgUi, updatedOrg: Partial) => void; } function getOrgProp(serverUrl: string, org: SalesforceOrgUi, skipFrontDoorAuth: boolean, prop: keyof SalesforceOrgUi, label?: string) { @@ -98,6 +99,7 @@ export const OrgInfoPopover: FunctionComponent = ({ org, loading, disableOrgActions, + isReadOnly = false, onAddOrg, onRemoveOrg, onUpdateOrg, @@ -125,7 +127,7 @@ export const OrgInfoPopover: FunctionComponent = ({ function handleFixOrg() { addOrg({ serverUrl: serverUrl, loginUrl: org.instanceUrl }, (addedOrg: SalesforceOrgUi) => { - onAddOrg(addedOrg, true); + onAddOrg?.(addedOrg, true); }); } @@ -146,12 +148,12 @@ export const OrgInfoPopover: FunctionComponent = ({ } function handleSave() { - onUpdateOrg(org, { label: orgLabel, color: getColor(orgColor) }); + onUpdateOrg?.(org, { label: orgLabel, color: getColor(orgColor) }); } function handleColorSelection(color: ColorSwatchItem) { setOrgColor(color.id); - onUpdateOrg(org, { label: org.label, color: getColor(color.id) }); + onUpdateOrg?.(org, { label: org.label, color: getColor(color.id) }); } async function handleClearCache() { @@ -242,40 +244,44 @@ export const OrgInfoPopover: FunctionComponent = ({ - - -
Label
- - -
- - - - {isDirty && ( - - - - - )} -
- - - - Color - - - - + {!isReadOnly && ( + <> + + +
Label
+ + +
+ + + + {isDirty && ( + + + + + )} +
+ + + + Color + + + + + + )} {getOrgProp(serverUrl, org, skipFrontDoorAuth, 'orgName', 'Org Name')} {getOrgProp(serverUrl, org, skipFrontDoorAuth, 'organizationId', 'Org Id')} {getOrgProp(serverUrl, org, skipFrontDoorAuth, 'orgInstanceName', 'Instance')} @@ -301,41 +307,43 @@ export const OrgInfoPopover: FunctionComponent = ({ -
- {!removeOrgActive && ( - - - - )} - {removeOrgActive && ( - -
-

This action will remove this org from jetstream,

-

are you sure you want to continue?

-
- - - - - - -
- )} -
+ {!isReadOnly && ( +
+ {!removeOrgActive && ( + + + + )} + {removeOrgActive && ( + +
+

This action will remove this org from jetstream,

+

are you sure you want to continue?

+
+ + + + + + +
+ )} +
+ )} } buttonProps={{ diff --git a/apps/jetstream/src/app/components/orgs/OrgPersistence.tsx b/libs/shared/ui-core/src/orgs/OrgPersistence.tsx similarity index 87% rename from apps/jetstream/src/app/components/orgs/OrgPersistence.tsx rename to libs/shared/ui-core/src/orgs/OrgPersistence.tsx index fd901a550..3acbfa816 100644 --- a/apps/jetstream/src/app/components/orgs/OrgPersistence.tsx +++ b/libs/shared/ui-core/src/orgs/OrgPersistence.tsx @@ -1,6 +1,6 @@ -import { STORAGE_KEYS, selectedOrgIdState } from '@jetstream/ui-core'; import { Fragment, FunctionComponent, useEffect } from 'react'; import { useRecoilState } from 'recoil'; +import { selectedOrgIdState, STORAGE_KEYS } from '../state-management/app-state'; export const OrgPersistence: FunctionComponent = () => { const [selectedOrgId] = useRecoilState(selectedOrgIdState); diff --git a/apps/jetstream/src/app/components/orgs/OrgSelectionRequired.tsx b/libs/shared/ui-core/src/orgs/OrgSelectionRequired.tsx similarity index 98% rename from apps/jetstream/src/app/components/orgs/OrgSelectionRequired.tsx rename to libs/shared/ui-core/src/orgs/OrgSelectionRequired.tsx index 21634da95..6a4a22c57 100644 --- a/apps/jetstream/src/app/components/orgs/OrgSelectionRequired.tsx +++ b/libs/shared/ui-core/src/orgs/OrgSelectionRequired.tsx @@ -3,9 +3,9 @@ import { logger } from '@jetstream/shared/client-logger'; import { checkOrgHealth, getOrgs } from '@jetstream/shared/data'; import { SalesforceOrgUi } from '@jetstream/types'; import { Alert, EmptyState, Icon, NoAccess2Illustration, fireToast } from '@jetstream/ui'; -import { fromAppState, fromJetstreamEvents } from '@jetstream/ui-core'; import { Fragment, FunctionComponent, useCallback, useState } from 'react'; import { useRecoilValue, useSetRecoilState } from 'recoil'; +import { fromAppState, fromJetstreamEvents } from '..'; import AddOrg from './AddOrg'; import { OrgWelcomeInstructions } from './OrgWelcomeInstructions'; diff --git a/apps/jetstream/src/app/components/orgs/OrgWelcomeInstructions.tsx b/libs/shared/ui-core/src/orgs/OrgWelcomeInstructions.tsx similarity index 100% rename from apps/jetstream/src/app/components/orgs/OrgWelcomeInstructions.tsx rename to libs/shared/ui-core/src/orgs/OrgWelcomeInstructions.tsx diff --git a/apps/jetstream/src/app/components/orgs/OrgsDropdown.tsx b/libs/shared/ui-core/src/orgs/OrgsDropdown.tsx similarity index 97% rename from apps/jetstream/src/app/components/orgs/OrgsDropdown.tsx rename to libs/shared/ui-core/src/orgs/OrgsDropdown.tsx index 6974cb868..c81816c12 100644 --- a/apps/jetstream/src/app/components/orgs/OrgsDropdown.tsx +++ b/libs/shared/ui-core/src/orgs/OrgsDropdown.tsx @@ -3,13 +3,13 @@ import { clearCacheForOrg, clearQueryHistoryForOrg, deleteOrg, getOrgs, updateOr import { useObservable } from '@jetstream/shared/ui-utils'; import { JetstreamEventAddOrgPayload, SalesforceOrgUi } from '@jetstream/types'; import { Badge, Grid, Icon, Tooltip } from '@jetstream/ui'; -import { OrgsCombobox, fromAppState, fromJetstreamEvents, useOrgPermissions } from '@jetstream/ui-core'; import classNames from 'classnames'; import orderBy from 'lodash/orderBy'; import uniqBy from 'lodash/uniqBy'; import { Fragment, FunctionComponent, useEffect, useState } from 'react'; import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil'; import { Observable } from 'rxjs'; +import { fromAppState, fromJetstreamEvents, OrgsCombobox, useOrgPermissions } from '..'; import AddOrg from './AddOrg'; import OrgInfoPopover from './OrgInfoPopover'; import OrgPersistence from './OrgPersistence'; diff --git a/libs/shared/ui-core/src/orgs/SelectedOrgReadOnly.tsx b/libs/shared/ui-core/src/orgs/SelectedOrgReadOnly.tsx new file mode 100644 index 000000000..76cb4385e --- /dev/null +++ b/libs/shared/ui-core/src/orgs/SelectedOrgReadOnly.tsx @@ -0,0 +1,48 @@ +import { Badge, Grid, Icon, Tooltip } from '@jetstream/ui'; +import classNames from 'classnames'; +import { Fragment } from 'react'; +import { useRecoilValue } from 'recoil'; +import { fromAppState, useOrgPermissions } from '..'; +import OrgInfoPopover from './OrgInfoPopover'; +import OrgPersistence from './OrgPersistence'; + +export const SelectedOrgReadOnly = () => { + const actionInProgress = useRecoilValue(fromAppState.actionInProgressState); + const selectedOrg = useRecoilValue(fromAppState.selectedOrgStateWithoutPlaceholder); + const orgType = useRecoilValue(fromAppState.selectedOrgType); + const { hasMetadataAccess } = useOrgPermissions(selectedOrg); + + return ( + + + + {!hasMetadataAccess && ( + +
+ + + Limited Access + +
+
+ )} +
+ {orgType && ( + + {orgType} + + )} +
+

{selectedOrg?.label}

+ {selectedOrg && ( +
+ {} +
+ )} +
+
+ ); +}; diff --git a/apps/jetstream/src/app/components/orgs/images/add-org.png b/libs/shared/ui-core/src/orgs/images/add-org.png similarity index 100% rename from apps/jetstream/src/app/components/orgs/images/add-org.png rename to libs/shared/ui-core/src/orgs/images/add-org.png diff --git a/apps/jetstream/src/app/components/orgs/images/org-info.png b/libs/shared/ui-core/src/orgs/images/org-info.png similarity index 100% rename from apps/jetstream/src/app/components/orgs/images/org-info.png rename to libs/shared/ui-core/src/orgs/images/org-info.png diff --git a/apps/jetstream/src/app/components/orgs/images/select-org.png b/libs/shared/ui-core/src/orgs/images/select-org.png similarity index 100% rename from apps/jetstream/src/app/components/orgs/images/select-org.png rename to libs/shared/ui-core/src/orgs/images/select-org.png diff --git a/libs/shared/ui-core/src/record/RecordSearchPopover.tsx b/libs/shared/ui-core/src/record/RecordSearchPopover.tsx index 0c0f58820..e44f1e87d 100644 --- a/libs/shared/ui-core/src/record/RecordSearchPopover.tsx +++ b/libs/shared/ui-core/src/record/RecordSearchPopover.tsx @@ -1,7 +1,16 @@ import { logger } from '@jetstream/shared/client-logger'; import { INDEXED_DB } from '@jetstream/shared/constants'; import { describeGlobal } from '@jetstream/shared/data'; -import { convertId15To18, hasModifierKey, isKKey, useGlobalEventHandler } from '@jetstream/shared/ui-utils'; +import { + appActionObservable$, + appActionRecordEventFilter, + convertId15To18, + hasModifierKey, + isKKey, + isValidSalesforceRecordId, + useGlobalEventHandler, + useObservable, +} from '@jetstream/shared/ui-utils'; import { CloneEditView, SalesforceOrgUi } from '@jetstream/types'; import { Grid, Icon, Input, KeyboardShortcut, Popover, PopoverRef, ScopedNotification, Spinner, getModifierKey } from '@jetstream/ui'; import localforage from 'localforage'; @@ -36,6 +45,21 @@ export const RecordSearchPopover: FunctionComponent = () => { const [sobjectName, setSobjectName] = useState(null); const [action, setAction] = useState('view'); + const appActionEvents = useObservable(appActionObservable$.pipe(appActionRecordEventFilter)); + + useEffect(() => { + if (appActionEvents && appActionEvents.action === 'VIEW_RECORD' && isValidSalesforceRecordId(appActionEvents.payload.recordId)) { + handleSubmit(null, appActionEvents.payload.recordId); + setRecordId(appActionEvents.payload.recordId); + setAction('view'); + } else if (appActionEvents && appActionEvents.action === 'EDIT_RECORD' && isValidSalesforceRecordId(appActionEvents.payload.recordId)) { + handleSubmit(null, appActionEvents.payload.recordId); + setRecordId(appActionEvents.payload.recordId); + setAction('edit'); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [appActionEvents]); + const getRecentRecords = useCallback(async () => { setRecordId(''); try { diff --git a/libs/shared/ui-core/src/state-management/app-state.ts b/libs/shared/ui-core/src/state-management/app-state.ts index 82ce168b0..2fe83b13e 100644 --- a/libs/shared/ui-core/src/state-management/app-state.ts +++ b/libs/shared/ui-core/src/state-management/app-state.ts @@ -1,13 +1,38 @@ +/// import { logger } from '@jetstream/shared/client-logger'; import { HTTP, INDEXED_DB } from '@jetstream/shared/constants'; import { checkHeartbeat, getOrgs, getUserProfile } from '@jetstream/shared/data'; -import { getOrgType, parseCookie } from '@jetstream/shared/ui-utils'; +import { getChromeExtensionVersion, getOrgType, isChromeExtension, parseCookie } from '@jetstream/shared/ui-utils'; import { groupByFlat } from '@jetstream/shared/utils'; import { ApplicationCookie, Maybe, SalesforceOrgUi, SalesforceOrgUiType, UserProfilePreferences, UserProfileUi } from '@jetstream/types'; import localforage from 'localforage'; import isString from 'lodash/isString'; import { atom, selector, useRecoilValue, useSetRecoilState } from 'recoil'; +const DEFAULT_PROFILE = { + email: 'unknown', + email_verified: true, + name: 'unknown', + nickname: 'unknown', + picture: 'unknown', + sub: 'unknown', + updated_at: 'unknown', + id: 'unknown', + userId: 'unknown', + createdAt: 'unknown', + updatedAt: 'unknown', + 'http://getjetstream.app/app_metadata': { + featureFlags: { + flagVersion: '', + flags: [], + isDefault: true, + }, + }, + preferences: { + skipFrontdoorLogin: true, + }, +} as UserProfileUi; + export const STORAGE_KEYS = { SELECTED_ORG_STORAGE_KEY: `SELECTED_ORG`, ANONYMOUS_APEX_STORAGE_KEY: `ANONYMOUS_APEX`, @@ -56,7 +81,7 @@ async function getUserPreferences(): Promise { async function getOrgsFromStorage(): Promise { try { - const orgs = await getOrgs(); + const orgs = isChromeExtension() ? [] : await getOrgs(); return orgs || []; } catch (ex) { return []; @@ -78,14 +103,15 @@ async function getSelectedOrgFromStorage(): Promise { async function fetchAppVersion() { try { - return await checkHeartbeat(); + return isChromeExtension() ? { version: getChromeExtensionVersion() } : await checkHeartbeat(); } catch (ex) { return { version: 'unknown' }; } } async function fetchUserProfile(): Promise { - const userProfile = await getUserProfile(); + // FIXME: this is a temporary fix to get the extension working, will want to fetch from server + const userProfile = isChromeExtension() ? DEFAULT_PROFILE : await getUserProfile(); return userProfile; } diff --git a/libs/shared/ui-core/tsconfig.lib.json b/libs/shared/ui-core/tsconfig.lib.json index ab59309fe..8ad846986 100644 --- a/libs/shared/ui-core/tsconfig.lib.json +++ b/libs/shared/ui-core/tsconfig.lib.json @@ -15,5 +15,5 @@ "src/**/*.spec.jsx", "src/**/*.test.jsx" ], - "include": ["src/**/*.js", "src/**/*.jsx", "src/**/*.ts", "src/**/*.tsx", "../../types/src/lib/ui/load-records-results-types.ts"] + "include": ["src/**/*.js", "src/**/*.jsx", "src/**/*.ts", "src/**/*.tsx", "../../../custom-typings/index.d.ts"] } diff --git a/libs/shared/ui-utils/src/index.ts b/libs/shared/ui-utils/src/index.ts index f3c7b9045..2bbad747c 100644 --- a/libs/shared/ui-utils/src/index.ts +++ b/libs/shared/ui-utils/src/index.ts @@ -17,7 +17,9 @@ export * from './lib/hooks/useProfilesAndPermSets'; export * from './lib/hooks/useRollbar'; export * from './lib/hooks/useTitle'; export * from './lib/queries'; +export * from './lib/shared-chrome-extension-helpers'; export * from './lib/shared-ui-data-utils'; export * from './lib/shared-ui-keyboard'; +export * from './lib/shared-ui-observables'; export * from './lib/shared-ui-query-utils'; export * from './lib/shared-ui-utils'; diff --git a/libs/shared/ui-utils/src/lib/shared-chrome-extension-helpers.ts b/libs/shared/ui-utils/src/lib/shared-chrome-extension-helpers.ts new file mode 100644 index 000000000..f236264b1 --- /dev/null +++ b/libs/shared/ui-utils/src/lib/shared-chrome-extension-helpers.ts @@ -0,0 +1,17 @@ +/// + +export const isChromeExtension = () => { + try { + return !!window?.chrome?.runtime?.id; + } catch (ex) { + return false; + } +}; + +export const getChromeExtensionVersion = () => { + try { + return window?.chrome?.runtime?.getManifest()?.version || 'UNKNOWN'; + } catch (ex) { + return 'UNKNOWN'; + } +}; diff --git a/libs/shared/ui-utils/src/lib/shared-ui-observables.ts b/libs/shared/ui-utils/src/lib/shared-ui-observables.ts new file mode 100644 index 000000000..61cd1a980 --- /dev/null +++ b/libs/shared/ui-utils/src/lib/shared-ui-observables.ts @@ -0,0 +1,23 @@ +import { filter, Subject } from 'rxjs'; + +export type AppAction = AppActionViewRecord | AppActionEditRecord; +export type AppActionTypes = AppActionViewRecord['action'] | AppActionEditRecord['action']; + +export type AppActionViewRecord = { + action: 'VIEW_RECORD'; + payload: { recordId: string }; +}; + +export type AppActionEditRecord = { + action: 'EDIT_RECORD'; + payload: { recordId: string }; +}; + +export const appActionObservable = new Subject(); +export const appActionObservable$ = appActionObservable.asObservable(); + +export const APP_ACTION_RECORD_EVENTS = new Set([ + 'EDIT_RECORD', + 'VIEW_RECORD', +]); +export const appActionRecordEventFilter = filter((action) => APP_ACTION_RECORD_EVENTS.has(action.action)); diff --git a/libs/shared/ui-utils/src/lib/shared-ui-utils.ts b/libs/shared/ui-utils/src/lib/shared-ui-utils.ts index 7b6f168c8..22ed60a1c 100644 --- a/libs/shared/ui-utils/src/lib/shared-ui-utils.ts +++ b/libs/shared/ui-utils/src/lib/shared-ui-utils.ts @@ -1462,6 +1462,32 @@ export function useReducerFetchFn() { return reducer; } +/** + * Validate if a string is a valid salesforce id + * https://gist.github.com/step307/3d265b7c7cb4eccdf0cf55a68c9cfefa + */ +export function isValidSalesforceRecordId(recordId?: string) { + if (!recordId || !/[a-z0-9]{15}|[a-z0-9]{18}/i.test(recordId)) { + return false; + } + if (recordId.length === 15) { + // no way to completely validate this + return true; + } + const upperCaseToBit = (char: string) => (char.match(/[A-Z]/) ? '1' : '0'); + const binaryToSymbol = (digit: number) => (digit <= 25 ? String.fromCharCode(digit + 65) : String.fromCharCode(digit - 26 + 48)); + + const parts = [ + recordId.slice(0, 5).split('').reverse().map(upperCaseToBit).join(''), + recordId.slice(5, 10).split('').reverse().map(upperCaseToBit).join(''), + recordId.slice(10, 15).split('').reverse().map(upperCaseToBit).join(''), + ]; + + const check = parts.map((str) => binaryToSymbol(parseInt(str, 2))).join(''); + + return check === recordId.slice(-3); +} + /** * Convert Salesforce 15 digit id to 18 digit id * If value is not in correct format, return original value diff --git a/libs/shared/utils/.eslintrc.json b/libs/shared/utils/.eslintrc.json index 6fe2e71e3..fb2af064a 100644 --- a/libs/shared/utils/.eslintrc.json +++ b/libs/shared/utils/.eslintrc.json @@ -9,6 +9,7 @@ "project": ["libs/shared/utils/tsconfig.*?.json"] }, "rules": { + "@typescript-eslint/ban-ts-comment": "warn", "no-restricted-imports": [ "error", { diff --git a/libs/shared/utils/src/lib/convert-quill-delta.ts b/libs/shared/utils/src/lib/convert-quill-delta.ts index dc5a76cd0..ac06d8c63 100644 --- a/libs/shared/utils/src/lib/convert-quill-delta.ts +++ b/libs/shared/utils/src/lib/convert-quill-delta.ts @@ -1,3 +1,4 @@ +// @ts-nocheck import isArray from 'lodash/isArray'; import isObject from 'lodash/isObject'; import isString from 'lodash/isString'; diff --git a/libs/shared/utils/src/lib/utils.ts b/libs/shared/utils/src/lib/utils.ts index 724d35236..84f90115c 100644 --- a/libs/shared/utils/src/lib/utils.ts +++ b/libs/shared/utils/src/lib/utils.ts @@ -980,8 +980,11 @@ export function flattenQueryColumn(column: QueryColumnMetadata, prevColumnPath?: } export function arrayBufferToBase64(buffer: ArrayBuffer): string { + return uint8ArrayToBase64(new Uint8Array(buffer)); +} + +export function uint8ArrayToBase64(bytes: Uint8Array): string { let binary = ''; - const bytes = new Uint8Array(buffer); const len = bytes.byteLength; for (let i = 0; i < len; i++) { binary += String.fromCharCode(bytes[i]); diff --git a/libs/ui/src/index.ts b/libs/ui/src/index.ts index 52d522ace..5234e00b7 100644 --- a/libs/ui/src/index.ts +++ b/libs/ui/src/index.ts @@ -8,21 +8,21 @@ export * from './lib/badge/BadgePopoverList'; export * from './lib/card/Card'; export * from './lib/color-picker/ColorSwatches'; export * from './lib/confirmation-dialog/ConfirmationDialog'; -export * from './lib/data-table/DataTable'; -export * from './lib/data-table/DataTableRenderers'; -export * from './lib/data-table/DataTree'; -export * from './lib/data-table/SalesforceRecordDataTable'; export * from './lib/data-table/data-table-context'; export * from './lib/data-table/data-table-formatters'; export type { ColumnWithFilter, ContextAction, ContextMenuActionData, DataTableRef, RowWithKey } from './lib/data-table/data-table-types'; export { getColumnsForGenericTable, getRowTypeFromValue, getSfdcRetUrl, setColumnFromType } from './lib/data-table/data-table-utils'; +export * from './lib/data-table/DataTable'; +export * from './lib/data-table/DataTableRenderers'; +export * from './lib/data-table/DataTree'; +export * from './lib/data-table/SalesforceRecordDataTable'; export * from './lib/docked-composer/DockedComposer'; -export * from './lib/expression-group/ExpressionContainer'; export * from './lib/expression-group/expression-utils'; +export * from './lib/expression-group/ExpressionContainer'; +export * from './lib/file-download-modal/download-modal-utils'; export * from './lib/file-download-modal/FileDownloadModal'; export * from './lib/file-download-modal/FileFauxDownloadModal'; export * from './lib/file-download-modal/RecordDownloadModal'; -export * from './lib/file-download-modal/download-modal-utils'; export * from './lib/form/button/ButtonGroupContainer'; export * from './lib/form/button/ButtonRowContainer'; export * from './lib/form/button/ButtonRowItem'; @@ -95,13 +95,13 @@ export * from './lib/layout/AutoFullHeightContainer'; export * from './lib/layout/ColumnWithMinWidth'; export * from './lib/layout/Header'; export * from './lib/layout/Page'; -export * from './lib/layout/Panel'; -export * from './lib/layout/ViewDocsLink'; export * from './lib/layout/page-header/PageHeader'; export * from './lib/layout/page-header/PageHeaderActions'; export * from './lib/layout/page-header/PageHeaderMetadataCol'; export * from './lib/layout/page-header/PageHeaderRow'; export * from './lib/layout/page-header/PageHeaderTitle'; +export * from './lib/layout/Panel'; +export * from './lib/layout/ViewDocsLink'; export * from './lib/list/List'; export * from './lib/list/ListItem'; export * from './lib/list/ListItemCheckbox'; @@ -140,6 +140,7 @@ export * from './lib/toolbar/ToolbarItemActions'; export * from './lib/toolbar/ToolbarItemGroup'; export * from './lib/tree/Tree'; export * from './lib/utils/ErrorBoundaryWithoutContent'; +export * from './lib/utils/OutsideClickHandler'; export * from './lib/widgets/Breadcrumbs'; export * from './lib/widgets/CopyToClipboard'; export * from './lib/widgets/CopyToClipboardWithToolTip'; diff --git a/libs/ui/src/lib/layout/Header.tsx b/libs/ui/src/lib/layout/Header.tsx index 709e5ff8a..4c9723bc1 100644 --- a/libs/ui/src/lib/layout/Header.tsx +++ b/libs/ui/src/lib/layout/Header.tsx @@ -10,6 +10,7 @@ export interface HeaderProps { userMenuItems: DropDownItem[]; rightHandMenuItems?: ReactNode; // notification?: ReactNode; + isChromeExtension?: boolean; onUserMenuItemSelected: (id: string) => void; children?: React.ReactNode; } @@ -20,6 +21,7 @@ export const Header: FunctionComponent = ({ orgs, rightHandMenuItems, userMenuItems, + isChromeExtension, onUserMenuItemSelected, children, }) => { @@ -32,6 +34,7 @@ export const Header: FunctionComponent = ({ orgs={orgs} rightHandMenuItems={rightHandMenuItems} userMenuItems={userMenuItems} + isChromeExtension={isChromeExtension} onUserMenuItemSelected={onUserMenuItemSelected} /> @@ -46,6 +49,7 @@ const HeaderContent: FunctionComponent> = ({ orgs, rightHandMenuItems, userMenuItems, + isChromeExtension, onUserMenuItemSelected, }) => { const [avatarSrc, setAvatarSrc] = useState(userProfile?.picture || Avatar); @@ -74,22 +78,24 @@ const HeaderContent: FunctionComponent> = ({ ) : (
  • {rightHandMenuItems}
  • )} -
  • -
    - - Avatar setAvatarSrc(Avatar)} /> - - } - position="right" - actionText="view user options" - items={userMenuItems} - onSelected={onUserMenuItemSelected} - /> -
    -
  • + {!isChromeExtension && ( +
  • +
    + + Avatar setAvatarSrc(Avatar)} /> + + } + position="right" + actionText="view user options" + items={userMenuItems} + onSelected={onUserMenuItemSelected} + /> +
    +
  • + )} diff --git a/libs/web-extension-utils/.eslintrc.json b/libs/web-extension-utils/.eslintrc.json new file mode 100644 index 000000000..9d9c0db55 --- /dev/null +++ b/libs/web-extension-utils/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "extends": ["../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/libs/web-extension-utils/README.md b/libs/web-extension-utils/README.md new file mode 100644 index 000000000..79d3d4eb8 --- /dev/null +++ b/libs/web-extension-utils/README.md @@ -0,0 +1,7 @@ +# web-extension-utils + +This library was generated with [Nx](https://nx.dev). + +## Running unit tests + +Run `nx test web-extension-utils` to execute the unit tests via [Jest](https://jestjs.io). diff --git a/libs/web-extension-utils/jest.config.ts b/libs/web-extension-utils/jest.config.ts new file mode 100644 index 000000000..b80fb65f2 --- /dev/null +++ b/libs/web-extension-utils/jest.config.ts @@ -0,0 +1,11 @@ +/* eslint-disable */ +export default { + displayName: 'web-extension-utils', + preset: '../../jest.preset.js', + testEnvironment: 'node', + transform: { + '^.+\\.[tj]s$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }], + }, + moduleFileExtensions: ['ts', 'js', 'html'], + coverageDirectory: '../../coverage/libs/web-extension-utils', +}; diff --git a/libs/web-extension-utils/project.json b/libs/web-extension-utils/project.json new file mode 100644 index 000000000..49f486ac8 --- /dev/null +++ b/libs/web-extension-utils/project.json @@ -0,0 +1,16 @@ +{ + "name": "web-extension-utils", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/web-extension-utils/src", + "projectType": "library", + "tags": [], + "targets": { + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/web-extension-utils/jest.config.ts" + } + } + } +} diff --git a/libs/web-extension-utils/src/index.ts b/libs/web-extension-utils/src/index.ts new file mode 100644 index 000000000..ab7a9532d --- /dev/null +++ b/libs/web-extension-utils/src/index.ts @@ -0,0 +1,3 @@ +export * from './lib/extension.types'; +export * from './lib/web-extension-localforage-driver'; +export * from './lib/web-extension-utils'; diff --git a/libs/web-extension-utils/src/lib/__tests__/web-extension-utils.spec.ts b/libs/web-extension-utils/src/lib/__tests__/web-extension-utils.spec.ts new file mode 100644 index 000000000..e96df2f6b --- /dev/null +++ b/libs/web-extension-utils/src/lib/__tests__/web-extension-utils.spec.ts @@ -0,0 +1,7 @@ +import { webExtensionUtils } from '../web-extension-utils'; + +describe('webExtensionUtils', () => { + it('should work', () => { + expect(webExtensionUtils()).toEqual('web-extension-utils'); + }); +}); diff --git a/libs/web-extension-utils/src/lib/extension.types.ts b/libs/web-extension-utils/src/lib/extension.types.ts new file mode 100644 index 000000000..03cf48b2a --- /dev/null +++ b/libs/web-extension-utils/src/lib/extension.types.ts @@ -0,0 +1,55 @@ +import { ApiConnection } from '@jetstream/salesforce-api'; +import { Maybe, SalesforceOrgUi } from '@jetstream/types'; + +export type Message = GetSfHost | GetSession | GetPageUrl | InitOrg; +export type MessageRequest = Message['request']; +export interface MessageResponse { + data: T; +} + +export interface SessionInfo { + hostname: string; + key: string; +} + +export interface GetSfHost { + request: { + message: 'GET_SF_HOST'; + data: { url: string }; + }; + response: Maybe; +} + +export interface GetSession { + request: { + message: 'GET_SESSION'; + data: { salesforceHost: string }; + }; + response: Maybe; +} + +export interface GetPageUrl { + request: { + message: 'GET_PAGE_URL'; + data: { page: string }; + }; + response: Maybe; +} + +export interface InitOrg { + request: { + message: 'INIT_ORG'; + data: { sessionInfo: SessionInfo }; + }; + response: { org: SalesforceOrgUi }; +} + +export interface OrgAndSessionInfo { + org: SalesforceOrgUi; + sessionInfo: SessionInfo; +} + +export interface OrgAndApiConnection { + org: SalesforceOrgUi; + apiConnection: ApiConnection; +} diff --git a/libs/web-extension-utils/src/lib/web-extension-localforage-driver.ts b/libs/web-extension-utils/src/lib/web-extension-localforage-driver.ts new file mode 100644 index 000000000..836aa8559 --- /dev/null +++ b/libs/web-extension-utils/src/lib/web-extension-localforage-driver.ts @@ -0,0 +1,180 @@ +/** + * All code was copied from https://github.com/eliihen/localforage-webExtensionStorage-driver?tab=readme-ov-file + * MIT licensed + */ +/* eslint-disable @typescript-eslint/no-empty-function */ +import { Maybe } from '@jetstream/types'; + +// Implement the driver here. +interface LocalForageDriver { + _driver: string; + _initStorage(options: LocalForageOptions): void; + _support?: boolean | LocalForageDriverSupportFunc; + getItem(key: string, callback?: (err?: Maybe, value?: T | null) => void): Promise; + setItem(key: string, value: T, callback?: (err?: Maybe, value?: T) => void): Promise; + removeItem(key: string, callback?: (err?: Maybe) => void): Promise; + clear(callback?: (err?: Maybe) => void): Promise; + length(callback?: (err?: Maybe, numberOfKeys?: number) => void): Promise; + key(keyIndex: number, callback?: (err: any, key?: string) => void): Promise; + keys(callback?: (err?: Maybe, keys?: string[]) => void): Promise; + iterate( + iteratee: (value: T, key: string, iterationNumber: number) => U, + callback?: (err?: Maybe, result?: U) => void + ): Promise; + dropInstance?: LocalForageDropInstanceFn; +} + +interface LocalForageDropInstanceFn { + (dbInstanceOptions?: LocalForageDbInstanceOptions, callback?: (err: any) => void): Promise; +} + +interface LocalForageDriverSupportFunc { + (): Promise; +} + +interface LocalForageDbInstanceOptions { + name?: string; + storeName?: string; +} + +interface LocalForageOptions extends LocalForageDbInstanceOptions { + driver?: string | string[]; + size?: number; + version?: number; + description?: string; +} + +export const LOCAL_DRIVER_NAME = 'webExtensionLocalStorage'; +export const localDriver = createDriver('webExtensionLocalStorage', 'local'); + +export const SYNC_DRIVER_NAME = 'webExtensionSyncStorage'; +export const syncDriver = createDriver('webExtensionSyncStorage', 'sync'); + +/** + * Need to invoke a function at runtime instead of import-time to make tests + * pass with mocked browser and chrome objects + */ +function getStorage() { + // @ts-expect-error browser and chrome are global objects + return (typeof browser !== 'undefined' && browser.storage) || (typeof chrome !== 'undefined' && chrome.storage); +} + +/** + * Need to invoke a function at runtime instead of import-time to make tests + * pass with mocked browser and chrome objects + */ +function usesPromises() { + const storage = getStorage(); + try { + return storage && storage.local.get && storage.local.get() && typeof storage.local.get().then === 'function'; + } catch (e) { + return false; + } +} + +/** + * Converts a callback-based API to a promise based API. + * For now we assume that there is only one arg in addition to the callback + */ +function usePromise(fn: (...args: any) => void, arg: any) { + if (usesPromises()) { + return fn(arg); + } + + return new Promise((resolve) => { + fn(arg, (...data: unknown[]) => { + // @ts-expect-error - this should be valid, but TS cannot infer it + resolve(...data); + }); + }); +} + +function createDriver(name: string, property: string): LocalForageDriver { + const storage = getStorage(); + const support = !!(storage && storage[property]); + + const driver = support + ? storage[property] + : { + clear() {}, + get() {}, + remove() {}, + set() {}, + }; + const clear = driver.clear.bind(driver); + const get = driver.get.bind(driver); + const remove = driver.remove.bind(driver); + const set = driver.set.bind(driver); + + const driverObj: LocalForageDriver = { + _driver: name, + _support: support, + // eslint-disable-next-line no-underscore-dangle + _initStorage() { + return Promise.resolve(); + }, + + async clear(callback) { + clear(); + + if (callback) callback(); + }, + + async iterate(iterator, callback) { + const items = (await usePromise(get, null)) as any; + const keys = Object.keys(items); + keys.forEach((key, i) => iterator(items[key], key, i)); + + if (callback) callback(null); + }, + + async getItem(key, callback) { + try { + let result = (await usePromise(get, key)) as any; + result = typeof key === 'string' ? result[key] : result; + result = result === undefined ? null : result; + + if (callback) callback(null, result); + return result; + } catch (e) { + if (callback) callback(e); + throw e; + } + }, + + async key(numberOfKeys, callback) { + const results = (await usePromise(get, null)) as any; + const key = Object.keys(results)[numberOfKeys]; + + if (callback) callback(key); + return key; + }, + + async keys(callback) { + const results = (await usePromise(get, null)) as any; + const keys = Object.keys(results); + + if (callback) callback(keys); + return keys; + }, + + async length(callback) { + const results = (await usePromise(get, null)) as any; + const { length } = Object.keys(results); + + if (callback) callback(length); + return length; + }, + + async removeItem(key, callback) { + await usePromise(remove, key); + if (callback) callback(); + }, + + async setItem(key, value, callback) { + await usePromise(set, { [key]: value }); + if (callback) callback(); + }, + }; + return driverObj; +} diff --git a/libs/web-extension-utils/src/lib/web-extension-utils.ts b/libs/web-extension-utils/src/lib/web-extension-utils.ts new file mode 100644 index 000000000..52d16f45e --- /dev/null +++ b/libs/web-extension-utils/src/lib/web-extension-utils.ts @@ -0,0 +1,96 @@ +import { enableLogger } from '@jetstream/shared/client-logger'; +import { createRoot } from 'react-dom/client'; +import { Message, MessageRequest, MessageResponse } from './extension.types'; + +type RequestResponseMap = { + [K in Message as K['request']['message']]: K; +}; + +// Helper type to extract the appropriate response type based on a given request type +type ResponseForRequest = R extends { message: infer M } + ? M extends keyof RequestResponseMap + ? RequestResponseMap[M]['response'] + : never + : never; + +function handleResponse(response: MessageResponse>) { + console.log('RESPONSE', response); + return response.data; +} + +export function initAndRenderReact( + content: Parameters['render']>[0], + { + elementId = 'app-container', + enableLogging = true, + }: { + elementId?: string; + enableLogging?: boolean; + } = { + elementId: 'app-container', + enableLogging: true, + } +) { + // Logging + enableLogging && enableLogger(true); + // Render + const container = document.getElementById(elementId); + const root = createRoot(container!); + root.render(content); +} + +export async function sendMessage(message: T): Promise> { + try { + return await chrome.runtime.sendMessage>>(message).then(handleResponse); + } catch (error) { + console.error('Error getting salesforce host', error); + throw error; + } +} + +// export async function getHost(url: string) { +// try { +// return await chrome.runtime +// .sendMessage>({ +// message: 'GET_SF_HOST', +// url, +// }) +// .then(handleResponse); +// } catch (error) { +// console.error('Error getting salesforce host', error); +// throw error; +// } +// } + +// export async function getSession(salesforceHost: string) { +// try { +// return await chrome.runtime +// .sendMessage>({ message: 'GET_SESSION', salesforceHost }) +// .then(handleResponse); +// } catch (error) { +// console.error('Error getting session', error); +// throw error; +// } +// } + +// export async function getPageUrl(page: string) { +// try { +// return await chrome.runtime +// .sendMessage>({ message: 'GET_PAGE_URL', page }) +// .then(handleResponse); +// } catch (error) { +// console.error('Error getting session', error); +// throw error; +// } +// } + +// export async function initOrg(sessionInfo: SessionInfo) { +// try { +// return await chrome.runtime +// .sendMessage>({ message: 'INIT_ORG', sessionInfo }) +// .then(handleResponse); +// } catch (error) { +// console.error('Error getting session', error); +// throw error; +// } +// } diff --git a/libs/web-extension-utils/tsconfig.json b/libs/web-extension-utils/tsconfig.json new file mode 100644 index 000000000..f45bf1c7d --- /dev/null +++ b/libs/web-extension-utils/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "allowJs": false, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "lib": ["DOM", "DOM.Iterable", "ESNext"], + "module": "ESNext", + "moduleResolution": "Node", + "strictNullChecks": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/libs/web-extension-utils/tsconfig.lib.json b/libs/web-extension-utils/tsconfig.lib.json new file mode 100644 index 000000000..08e5e177b --- /dev/null +++ b/libs/web-extension-utils/tsconfig.lib.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "types": ["node", "chrome"] + }, + "include": ["src/**/*.ts", "../../custom-typings/index.d.ts"], + "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"] +} diff --git a/libs/web-extension-utils/tsconfig.spec.json b/libs/web-extension-utils/tsconfig.spec.json new file mode 100644 index 000000000..f6d8ffcc9 --- /dev/null +++ b/libs/web-extension-utils/tsconfig.spec.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "types": ["jest", "node"] + }, + "include": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts"] +} diff --git a/nx.json b/nx.json index 3132f15f2..6db1a07c2 100644 --- a/nx.json +++ b/nx.json @@ -59,9 +59,10 @@ }, "@nx/react": { "application": { + "babel": true, "style": "@emotion/styled", "linter": "eslint", - "babel": true + "bundler": "vite" }, "library": { "style": "@emotion/styled", @@ -129,6 +130,11 @@ "cache": true, "dependsOn": ["^build"], "inputs": ["production", "^production"] + }, + "@nx/js:tsc": { + "cache": true, + "dependsOn": ["^build"], + "inputs": ["production", "^production"] } }, "namedInputs": { diff --git a/package.json b/package.json index f0e6201f0..f78bbabcf 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "start:prod": "NODE_ENV=production node dist/apps/api/main.js", "start:staging": "NODE_ENV=production node dist/apps/api/main.js", "start:e2e": "node dist/apps/api/main.js", + "start:web-extension": "nx run jetstream-web-extension:serve", "start:cron:inactive-account-warning": "node dist/apps/cron-tasks/inactive-account-warning.js", "start:cron:inactive-account-deletion": "node dist/apps/cron-tasks/inactive-account-deletion.js", "start:cron:save-analytics-summary": "node dist/apps/cron-tasks/save-analytics-summary.js", @@ -49,6 +50,7 @@ "build:landing:sitemap": "next-sitemap", "build:docs": "cd apps/docs && yarn build", "build:storybook": "storybook build -c libs/ui/.storybook -o dist/storybook -s node_modules/@salesforce-ux/design-system/assets/styles", + "build:web-extension": "nx run jetstream-web-extension:build", "release": "dotenv release-it ${0}", "release:build": "zx ./scripts/build-release.mjs", "bundle-analyzer:client": "npx webpack-bundle-analyzer dist/apps/jetstream/stats.json", @@ -111,6 +113,7 @@ "@nx/webpack": "19.3.2", "@nx/workspace": "19.3.2", "@playwright/test": "^1.43.1", + "@pmmmwh/react-refresh-webpack-plugin": "^0.5.7", "@release-it/bumper": "^4.0.2", "@release-it/conventional-changelog": "^5.0.0", "@salesforce-ux/design-system": "^2.23.2", @@ -129,6 +132,7 @@ "@testing-library/react": "15.0.6", "@testing-library/user-event": "^13.0.7", "@types/amplitude-js": "^8.16.5", + "@types/chrome": "^0.0.268", "@types/connect-pg-simple": "^4.2.2", "@types/cors": "^2.8.17", "@types/express": "^4.17.21", @@ -157,7 +161,6 @@ "@types/react-transition-group": "^4.4.9", "@types/sass": "^1.45.0", "@types/webpack": "4.41.21", - "@types/xml2js": "^0.4.14", "@typescript-eslint/eslint-plugin": "7.7.1", "@typescript-eslint/parser": "7.7.1", "@vitejs/plugin-react": "4.2.1", @@ -166,6 +169,7 @@ "autoprefixer": "10.4.13", "babel-jest": "29.4.3", "babel-loader": "^9.1.3", + "chrome-types": "^0.1.291", "concurrently": "^7.2.1", "contentful": "^8.1.8", "cross-env": "^7.0.3", @@ -182,6 +186,7 @@ "eslint-plugin-react-hooks": "^4.6.2", "eslint-plugin-storybook": "^0.8.0", "git-revision-webpack-plugin": "^5.0.0", + "html-webpack-plugin": "^5.6.0", "jest": "29.4.3", "jest-environment-jsdom": "29.4.3", "jest-environment-node": "^29.4.1", @@ -262,6 +267,7 @@ "express-http-proxy": "^1.6.2", "express-promise-router": "^4.1.1", "express-session": "^1.18.0", + "fast-xml-parser": "^4.4.0", "fastify": "^3.29.4", "file-saver": "^2.0.5", "filesize": "^10.1.1", @@ -319,10 +325,10 @@ "socket.io": "^4.2.0", "socket.io-client": "^4.2.0", "split.js": "^1.6.5", + "tiny-request-router": "^1.2.2", "tslib": "^2.3.0", "uuid": "^9.0.1", "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.2/xlsx-0.20.2.tgz", - "xml2js": "^0.6.2", "xmlbuilder2": "^3.1.1", "zod": "^3.23.4" } diff --git a/tsconfig.base.json b/tsconfig.base.json index c7cbd83d5..6bc683a52 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -50,6 +50,7 @@ "@jetstream/ui": ["libs/ui/src/index.ts"], "@jetstream/ui-core": ["libs/shared/ui-core/src/index.ts"], "@jetstream/ui-core/shared": ["libs/shared/ui-core-shared/src/index.ts"], + "@jetstream/web-extension-utils": ["libs/web-extension-utils/src/index.ts"], "@jetstream/workspace-plugin": ["tools/workspace-plugin/src/index.ts"] } }, diff --git a/vitest.workspace.ts b/vitest.workspace.ts new file mode 100644 index 000000000..3c983a248 --- /dev/null +++ b/vitest.workspace.ts @@ -0,0 +1 @@ +export default ['**/*/vite.config.ts', '**/*/vitest.config.ts']; diff --git a/yarn.lock b/yarn.lock index 5efb189ee..59d4a46bb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7084,6 +7084,19 @@ schema-utils "^3.0.0" source-map "^0.7.3" +"@pmmmwh/react-refresh-webpack-plugin@^0.5.7": + version "0.5.15" + resolved "https://registry.yarnpkg.com/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.15.tgz#f126be97c30b83ed777e2aeabd518bc592e6e7c4" + integrity sha512-LFWllMA55pzB9D34w/wXUCf8+c+IYKuJDgxiZ3qMhl64KRMBHYM1I3VdGaD2BV5FNPV2/S2596bppxHbv2ZydQ== + dependencies: + ansi-html "^0.0.9" + core-js-pure "^3.23.3" + error-stack-parser "^2.0.6" + html-entities "^2.1.0" + loader-utils "^2.0.4" + schema-utils "^4.2.0" + source-map "^0.7.3" + "@pnpm/network.ca-file@^1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@pnpm/network.ca-file/-/network.ca-file-1.0.1.tgz#16f88d057c68cd5419c1ef3dfa281296ea80b047" @@ -9390,6 +9403,14 @@ "@types/node" "*" "@types/responselike" "*" +"@types/chrome@^0.0.268": + version "0.0.268" + resolved "https://registry.yarnpkg.com/@types/chrome/-/chrome-0.0.268.tgz#d5855546f30c83e181cadd77127a162c25b480d2" + integrity sha512-7N1QH9buudSJ7sI8Pe4mBHJr5oZ48s0hcanI9w3wgijAlv1OZNUZve9JR4x42dn5lJ5Sm87V1JNfnoh10EnQlA== + dependencies: + "@types/filesystem" "*" + "@types/har-format" "*" + "@types/component-emitter@^1.2.10": version "1.2.11" resolved "https://registry.npmjs.org/@types/component-emitter/-/component-emitter-1.2.11.tgz" @@ -9575,6 +9596,18 @@ resolved "https://registry.npmjs.org/@types/file-saver/-/file-saver-2.0.5.tgz" integrity sha512-zv9kNf3keYegP5oThGLaPk8E081DFDuwfqjtiTzm6PoxChdJ1raSuADf2YGCVIyrSynLrgc8JWv296s7Q7pQSQ== +"@types/filesystem@*": + version "0.0.36" + resolved "https://registry.yarnpkg.com/@types/filesystem/-/filesystem-0.0.36.tgz#7227c2d76bfed1b21819db310816c7821d303857" + integrity sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA== + dependencies: + "@types/filewriter" "*" + +"@types/filewriter@*": + version "0.0.33" + resolved "https://registry.yarnpkg.com/@types/filewriter/-/filewriter-0.0.33.tgz#d9d611db9d9cd99ae4e458de420eeb64ad604ea8" + integrity sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g== + "@types/find-cache-dir@^3.2.1": version "3.2.1" resolved "https://registry.yarnpkg.com/@types/find-cache-dir/-/find-cache-dir-3.2.1.tgz#7b959a4b9643a1e6a1a5fe49032693cc36773501" @@ -9684,6 +9717,11 @@ dependencies: "@types/node" "*" +"@types/har-format@*": + version "1.2.15" + resolved "https://registry.yarnpkg.com/@types/har-format/-/har-format-1.2.15.tgz#f352493638c2f89d706438a19a9eb300b493b506" + integrity sha512-RpQH4rXLuvTXKR0zqHq3go0RVXYv/YVqv4TnPH95VbwUxZdQlK1EtcMvQvMpDngHbt13Csh9Z4qT9AbkiQH5BA== + "@types/history@^4.7.11": version "4.7.11" resolved "https://registry.npmjs.org/@types/history/-/history-4.7.11.tgz" @@ -10322,13 +10360,6 @@ dependencies: "@types/node" "*" -"@types/xml2js@^0.4.14": - version "0.4.14" - resolved "https://registry.yarnpkg.com/@types/xml2js/-/xml2js-0.4.14.tgz#5d462a2a7330345e2309c6b549a183a376de8f9a" - integrity sha512-4YnrRemBShWRO2QjvUin8ESA41rH+9nQGLUGZV/1IDhi3SL9OhdpNC/MrulTWuptXKwhx/aDxE7toV0f/ypIXQ== - dependencies: - "@types/node" "*" - "@types/yargs-parser@*": version "21.0.0" resolved "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.0.tgz" @@ -11050,6 +11081,11 @@ ansi-html-community@0.0.8, ansi-html-community@^0.0.8: resolved "https://registry.npmjs.org/ansi-html-community/-/ansi-html-community-0.0.8.tgz" integrity sha512-1APHAyr3+PCamwNw3bXCPp4HFLONZt/yIH0sZp0/469KWNTEy+qN5jQ3GVX6DMZ1UXAi34yVwtTeaG/HpBuuzw== +ansi-html@^0.0.9: + version "0.0.9" + resolved "https://registry.yarnpkg.com/ansi-html/-/ansi-html-0.0.9.tgz#6512d02342ae2cc68131952644a129cb734cd3f0" + integrity sha512-ozbS3LuenHVxNRh/wdnN16QapUHzauqSomAl1jwwJRRsGwFwtj644lIhxfWu0Fy0acCij2+AEgHvjscq3dlVXg== + ansi-regex@^5.0.1: version "5.0.1" resolved "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz" @@ -12498,6 +12534,11 @@ chrome-trace-event@^1.0.2: resolved "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz" integrity sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg== +chrome-types@^0.1.291: + version "0.1.291" + resolved "https://registry.yarnpkg.com/chrome-types/-/chrome-types-0.1.291.tgz#2d7a992724dccb8c88789ea495b61ddd6a75ebf1" + integrity sha512-ekje3T39gTfgxItuu9vC9lj2Wf+Z20i/PR2oth9EmlLimEdJ9FDvYocPMxCoF1FAJrzZXGValhDvlqWB2KRBVg== + ci-info@^3.2.0: version "3.3.2" resolved "https://registry.npmjs.org/ci-info/-/ci-info-3.3.2.tgz" @@ -15558,6 +15599,13 @@ fast-safe-stringify@^2.0.8, fast-safe-stringify@^2.1.1: resolved "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz" integrity sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA== +fast-xml-parser@^4.4.0: + version "4.4.0" + resolved "https://registry.yarnpkg.com/fast-xml-parser/-/fast-xml-parser-4.4.0.tgz#341cc98de71e9ba9e651a67f41f1752d1441a501" + integrity sha512-kLY3jFlwIYwBNDojclKsNAC12sfD6NwW74QB2CoNGPvtVxjliYehVunB3HYyNi+n4Tt1dAcgwYvmKF/Z18flqg== + dependencies: + strnum "^1.0.5" + fastify@^3.29.4: version "3.29.4" resolved "https://registry.yarnpkg.com/fastify/-/fastify-3.29.4.tgz#294e33017b55f3cb72f315c41cf51431bc9b7a34" @@ -16913,6 +16961,17 @@ html-webpack-plugin@^5.5.0: pretty-error "^4.0.0" tapable "^2.0.0" +html-webpack-plugin@^5.6.0: + version "5.6.0" + resolved "https://registry.yarnpkg.com/html-webpack-plugin/-/html-webpack-plugin-5.6.0.tgz#50a8fa6709245608cb00e811eacecb8e0d7b7ea0" + integrity sha512-iwaY4wzbe48AfKLZ/Cc8k0L+FKG6oSNRaZ8x5A/T/IVDGyXcbHncM9TdDa93wn0FsSm82FhTKW7f3vS61thXAw== + dependencies: + "@types/html-minifier-terser" "^6.0.0" + html-minifier-terser "^6.0.2" + lodash "^4.17.21" + pretty-error "^4.0.0" + tapable "^2.0.0" + htmlparser2@^6.1.0: version "6.1.0" resolved "https://registry.npmjs.org/htmlparser2/-/htmlparser2-6.1.0.tgz" @@ -20900,6 +20959,11 @@ path-to-regexp@0.1.7: resolved "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz" integrity sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ== +path-to-regexp@^6.1.0: + version "6.2.2" + resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-6.2.2.tgz#324377a83e5049cbecadc5554d6a63a9a4866b36" + integrity sha512-GQX3SSMokngb36+whdpRXE+3f9V8UzyAorlYvOGx87ufGHehNTn5lCxrKtLyZ4Yl/wEKnNnr98ZzOwwDZV5ogw== + path-type@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/path-type/-/path-type-3.0.0.tgz" @@ -23415,7 +23479,7 @@ sass@^1.42.1: immutable "^4.0.0" source-map-js ">=0.6.2 <2.0.0" -sax@>=0.6.0, sax@^1.2.4, sax@~1.2.4: +sax@^1.2.4, sax@~1.2.4: version "1.2.4" resolved "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz" integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw== @@ -23479,7 +23543,7 @@ schema-utils@^4.0.0: ajv-formats "^2.1.1" ajv-keywords "^5.0.0" -schema-utils@^4.0.1: +schema-utils@^4.0.1, schema-utils@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-4.2.0.tgz#70d7c93e153a273a805801882ebd3bff20d89c8b" integrity sha512-L0jRsrPpjdckP3oPug3/VxNKt2trR8TcabrM6FOAAlvC/9Phcmm+cuAgTlxBqdBR1WJx7Naj9WHw+aOmheSVbw== @@ -24454,6 +24518,11 @@ strip-outer@^2.0.0: resolved "https://registry.yarnpkg.com/strip-outer/-/strip-outer-2.0.0.tgz#c45c724ed9b1ff6be5f660503791404f4714084b" integrity sha512-A21Xsm1XzUkK0qK1ZrytDUvqsQWict2Cykhvi0fBQntGG5JSprESasEyV1EZ/4CiR5WB5KjzLTrP/bO37B0wPg== +strnum@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/strnum/-/strnum-1.0.5.tgz#5c4e829fe15ad4ff0d20c3db5ac97b73c9b072db" + integrity sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA== + strong-log-transformer@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/strong-log-transformer/-/strong-log-transformer-2.1.0.tgz#0f5ed78d325e0421ac6f90f7f10e691d6ae3ae10" @@ -24842,6 +24911,13 @@ tiny-lru@^8.0.1: resolved "https://registry.npmjs.org/tiny-lru/-/tiny-lru-8.0.2.tgz" integrity sha512-ApGvZ6vVvTNdsmt676grvCkUCGwzG9IqXma5Z07xJgiC5L7akUMof5U8G2JTI9Rz/ovtVhJBlY6mNhEvtjzOIg== +tiny-request-router@^1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/tiny-request-router/-/tiny-request-router-1.2.2.tgz#1b80694497e4e8dcbb8e93851ec7f03c6ca13e75" + integrity sha512-6ZMFU7AP9so+hkqmMM9fJ11V44EAcYuHCmNdsyM8k94oVnNDPQwUAAPoBHqchHSpKG6yZbCasgVeRxaY5v2BCg== + dependencies: + path-to-regexp "^6.1.0" + tinybench@^2.5.1: version "2.6.0" resolved "https://registry.yarnpkg.com/tinybench/-/tinybench-2.6.0.tgz#1423284ee22de07c91b3752c048d2764714b341b" @@ -26363,14 +26439,6 @@ xml-name-validator@^4.0.0: resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-4.0.0.tgz#79a006e2e63149a8600f15430f0a4725d1524835" integrity sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw== -xml2js@^0.6.2: - version "0.6.2" - resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.6.2.tgz#dd0b630083aa09c161e25a4d0901e2b2a929b499" - integrity sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA== - dependencies: - sax ">=0.6.0" - xmlbuilder "~11.0.0" - xmlbuilder2@^3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/xmlbuilder2/-/xmlbuilder2-3.1.1.tgz#b977ef8a6fb27a1ea7ffa7d850d2c007ff343bc0" @@ -26381,11 +26449,6 @@ xmlbuilder2@^3.1.1: "@oozcitak/util" "8.3.8" js-yaml "3.14.1" -xmlbuilder@~11.0.0: - version "11.0.1" - resolved "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz" - integrity sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA== - xmlchars@^2.2.0: version "2.2.0" resolved "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz"