From da8450a1366069d6c5597e5c84b801f580c37287 Mon Sep 17 00:00:00 2001 From: Bradley Momberger Date: Mon, 9 Dec 2024 23:54:36 -0500 Subject: [PATCH 1/6] [TR-79] Initial commit for routing through the Atlassian Connect API --- package-lock.json | 17 ++ package.json | 1 + public/jira/history/hooks.ts | 41 +++++ public/jira/history/observable.ts | 265 +++++++++++++++++++++++++++++ public/react/Stats/Stats.tsx | 4 +- public/reports/table-grid.js | 16 +- public/shared/main-helper.js | 2 +- public/shared/route-pushstate.ts | 54 ++++++ public/shared/state-storage.js | 15 +- rollup.config.mjs | 20 ++- scripts/atlassian-connect/index.ts | 6 +- 11 files changed, 421 insertions(+), 20 deletions(-) create mode 100644 public/jira/history/hooks.ts create mode 100644 public/jira/history/observable.ts create mode 100644 public/shared/route-pushstate.ts diff --git a/package-lock.json b/package-lock.json index 5d0d2297..e07b75f3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "@atlaskit/toggle": "^13.4.4", "@atlaskit/tokens": "^2.0.0", "@atlaskit/tooltip": "^18.8.3", + "@rollup/plugin-alias": "^5.1.1", "@rollup/plugin-babel": "^6.0.4", "@tailwindcss/container-queries": "^0.1.1", "@tanstack/react-query": "^5.56.2", @@ -3347,6 +3348,22 @@ "url": "https://opencollective.com/popperjs" } }, + "node_modules/@rollup/plugin-alias": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@rollup/plugin-alias/-/plugin-alias-5.1.1.tgz", + "integrity": "sha512-PR9zDb+rOzkRb2VD+EuKB7UC41vU5DIwZ5qqCpk0KJudcWAyi8rvYOhS7+L5aZCspw1stTViLgN5v6FF1p5cgQ==", + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, "node_modules/@rollup/plugin-babel": { "version": "6.0.4", "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-6.0.4.tgz", diff --git a/package.json b/package.json index 07340581..d2ed092b 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "@atlaskit/toggle": "^13.4.4", "@atlaskit/tokens": "^2.0.0", "@atlaskit/tooltip": "^18.8.3", + "@rollup/plugin-alias": "^5.1.1", "@rollup/plugin-babel": "^6.0.4", "@tailwindcss/container-queries": "^0.1.1", "@tanstack/react-query": "^5.56.2", diff --git a/public/jira/history/hooks.ts b/public/jira/history/hooks.ts new file mode 100644 index 00000000..e615fce9 --- /dev/null +++ b/public/jira/history/hooks.ts @@ -0,0 +1,41 @@ +import { useEffect, useState } from 'react'; +import { pushStateObservable } from '../../shared/state-storage'; + +export const useJiraStateValue: + (key: string) => [string | undefined, (val: string | undefined) => void] = + (key) => { + const [value, setValue] = useState(pushStateObservable.get(key)); + useEffect(() => { + const handler = (newVal?: string) => { + setValue(newVal); + }; + + pushStateObservable.on(key, handler); + + const setValue = (val: string | undefined) => pushStateObservable.set(key, val); + + return () => pushStateObservable.off(key, handler); + }, []); + + return [value, setValue]; + }; + +export const useJiraState: + () => [Record, (val: Record) => void] = + () => { + const [value, setValue] = useState>((pushStateObservable.get(undefined) ?? {})); + useEffect(() => { + const handler = (newVal: Record) => { + setValue(newVal ?? {}); + }; + + pushStateObservable.on(undefined, handler); + + const setValue = (val: Record) => pushStateObservable.set(val); + + return () => pushStateObservable.off(undefined, handler); + }, []); + + return [value, setValue]; + }; + diff --git a/public/jira/history/observable.ts b/public/jira/history/observable.ts new file mode 100644 index 00000000..653c881b --- /dev/null +++ b/public/jira/history/observable.ts @@ -0,0 +1,265 @@ +interface JiraLocationState { + key: string; + hash: string | null; + query?: Record | null; + state?: Record | null; + title: string; + href: string; +} + +interface JiraPushLocationState extends Omit { + query?: Record | null; +} + +type CanPatch = { + type: 'add' | 'set'; + key: string; + value: any; +} | { + type: 'delete'; + key: string; +} + +declare global { + interface AP { + history: { + getState: ( + type?: T, + callback?: (state: JiraLocationState) => void + ) => (T extends 'all' ? JiraLocationState : string); + replaceState: (state: Partial) => void; + subscribeState: (type: string, callback: (state: JiraLocationState) => void) => void; + } + } +} + +const isValueLikeSymbol = Symbol.for("can.isValueLike"); +const isMapLikeSymbol = Symbol.for("can.isMapLike"); +const onKeyValueSymbol = Symbol.for("can.onKeyValue"); +const offKeyValueSymbol = Symbol.for("can.offKeyValue"); +const getKeyValueSymbol = Symbol.for("can.getKeyValue"); +const setKeyValueSymbol = Symbol.for("can.setKeyValue"); + +const onPatchesSymbol = Symbol.for("can.onPatches"); +const offPatchesSymbol = Symbol.for("can.offPatches"); + +type KeyHandler = (newValue: any, oldValue: any) => void; +export interface JiraStateSync { + [onKeyValueSymbol]: (key: string, handler: KeyHandler, queue?: string) => void; + [offKeyValueSymbol]: (key: string, handler: KeyHandler, queue?: string) => void; + [onPatchesSymbol]: (handler: KeyHandler, queue?: string) => void; + [offPatchesSymbol]: (handler: KeyHandler, queue?: string) => void; + [getKeyValueSymbol]: (key: string) => string | undefined; + [setKeyValueSymbol]: (key: string, value: string | null) => void; + [isMapLikeSymbol]: true, + [isValueLikeSymbol]: true, + on: (key: string | undefined, handler: KeyHandler, queue?: string) => void; + off: (key: string | undefined, handler: KeyHandler, queue?: string) => void; + get: (key: T) => T extends string ? (string | undefined) : Record; + set: (...args: [string, string | null] | [state: Record]) => void; +} + +const handlers = new Map>(); +const patchHandlers = new Map(); +const valueHandlers = new Set(); +let lastQuery: Record | null = null; + +const stateApi: Pick = { + on: function(key, handler, queue) { + if (!key) { + valueHandlers.add(handler); + } else if (key === 'can.patches') { + patchHandlers.set(handler, (patches) => { + if (patches.length) { + handler(patches, undefined); + } + }) + } else if (handlers.has(key)) { + const keyHandlers = handlers.get(key)!; + keyHandlers.add(handler); + } else { + const handlerSet = new Set(); + handlerSet.add(handler); + handlers.set(key, handlerSet); + } + }, + off: function(key, handler, queue) { + if (!key) { + valueHandlers.delete(handler); + } else if (key === 'can.patches') { + patchHandlers.delete(handler); + } else if (handlers.has(key)) { + const keyHandlers = handlers.get(key)!; + keyHandlers.delete(handler); + } + }, + get: function(key?: string) { + const encodedVal = decodeQuery(AP?.history.getState('all').query); + if (arguments.length > 0) { + return encodedVal?.[key!]; + } else { + return encodedVal ?? {}; + } + } as JiraStateSync['get'], + set(...args) { + const [keyOrState, val] = args; + const { query: currentState = {} } = AP?.history.getState('all') ?? {}; + let newState: Record; + if (typeof keyOrState === 'string') { + newState = { + ...currentState, + [keyOrState]: val ? encodeURIComponent(val) : null, + }; + } else { + const changedState = encodeQuery(keyOrState); + + newState = { + ...currentState, + ...changedState as Record, + }; + } + if (Object.keys(newState).reduce((a, b) => a && (newState[b] === currentState?.[b]), true)) { + // no keys have changed. + // Do not dispatch a new state to the AP history + return; + } + + AP?.history.replaceState({ query: newState }) + } +} + +const dispatchKeyHandlers: (key: string, newValue?: string | null, oldValue?: string | null) => void = (key, newValue, oldValue) => { + const keyHandlers = handlers.get(key); + if(keyHandlers) { + keyHandlers.forEach(handler => { + handler(newValue, oldValue); + }) + } +} + +const decodeQuery = ( + query?: Record | null +): Record => + Object.entries(query ?? {}).reduce( + (a, [key, val]) => ({ ...a, [key]: val ? decodeURIComponent(val) : val }), + {} + ); +const encodeQuery = ( + query?: Record | null +) => + Object.entries(query ?? {}).reduce( + (a, [key, val]) => ({ ...a, [key]: val ? encodeURIComponent(val) : null }), + {} + ) as Record; + +export const browserState: JiraStateSync = { + ...stateApi, + [onKeyValueSymbol]: stateApi.on, + [offKeyValueSymbol]: stateApi.off, + [onPatchesSymbol]: stateApi.on.bind(null, 'can.patches'), + [offPatchesSymbol]: stateApi.off.bind(null, 'can.patches'), + [getKeyValueSymbol]: stateApi.get, + [setKeyValueSymbol]: stateApi.set as JiraStateSync[typeof setKeyValueSymbol], + [isValueLikeSymbol]: true, + [isMapLikeSymbol]: true, +}; + +AP?.history?.subscribeState("change", ({ query }: { query?: Record | null; }) => { + if (!lastQuery && !query) { + return; + } + + const decodedQuery = decodeQuery(query); + + if (query) { + Object.entries(decodedQuery).forEach(([key, val]) => { + if (val !== lastQuery?.[key]) { + dispatchKeyHandlers(key, val, lastQuery?.[key]); + } + }); + } + if (patchHandlers.size > 0) { + const patches = []; + if (lastQuery == null) { + const newEntries = Object.entries(decodedQuery); + patches.push(...newEntries.map(([key, val]) => ({ key, type: 'add', value: val }))); + } else if (query == null) { + const oldEntries = Object.entries(lastQuery); + patches.push(...oldEntries.map(([key]) => ({ key, type: 'delete' }))); + } else { + const newKeys = Object.keys(decodedQuery); + const oldKeys = Object.keys(lastQuery); + const adds = newKeys.filter(key => !oldKeys.includes(key)); + const dels = oldKeys.filter(key => !newKeys.includes(key)); + const sets = newKeys.filter(key => !adds.includes(key)); + + patches.push( + ...dels.map(key => ({ type: 'delete', key })), + ...sets.filter(key => query[key] !== lastQuery?.[key]).map(key => ({ type: 'set', key, value: decodedQuery[key] })), + ...adds.map(key => ({ type: 'add', key, value: decodedQuery[key] })), + ); + } + for(const handler of patchHandlers.values()) { + handler(patches, undefined); + }; + } + // Value handlers are dispatched after patch handlers + // because the local URL is being updated by a patch + // handler, while the observables looking at the URL + // are using listenTo() which relies on value handlers + valueHandlers.forEach(handler => { + handler(query ? decodedQuery : query, lastQuery); + }); + + lastQuery = query ?? null; +}); +export default browserState; + +// This listener keeps the frame URL search in sync with +// the outer page's `ac` params, allowing our existing +// observables to continue using the interior URL for +// values. +browserState.on('can.patches', (patches: CanPatch[]) => { + const currentParams = new URLSearchParams(location.search); + + patches.forEach((patch) => { + if (patch.type === 'delete') { + currentParams.delete(patch.key); + } else { + currentParams.set(patch.key, patch.value); + } + }) + + history.pushState({}, '', `?${currentParams.toString()}`); +}); + +const keyblocklist = [ + "xdm_e", + "xdm_c", + "cp", + "xdm_deprecated_addon_key_do_not_use", + "lic", + "cv", +]; +window.addEventListener('popstate', () => { + const { search } = window.location; + const searchState = encodeQuery( + Array.from(new URLSearchParams(search).entries()).filter(([key]) => !keyblocklist.includes(key)).reduce( + (a, [key, val]) => ({ ...a, [key]: val }) + , {}) + ); + AP?.history.replaceState(searchState); +}); + +AP?.history?.getState('all', ({ query }) => { + const decodedQuery = decodeQuery(query); + const currentParams = new URLSearchParams(location.search); + Object.entries(decodedQuery).forEach(([key, val]) => { + currentParams.set(key, val!); + }); + + history.replaceState({}, '', `?${currentParams.toString()}`); +}); + +export const underlyingReplaceState = history.replaceState; +export const pushStateObservable = browserState; diff --git a/public/react/Stats/Stats.tsx b/public/react/Stats/Stats.tsx index 0f5fea22..79543e72 100644 --- a/public/react/Stats/Stats.tsx +++ b/public/react/Stats/Stats.tsx @@ -2,6 +2,7 @@ import type { FC } from "react"; import React, { useState, useEffect } from "react"; import type { DerivedIssue } from "../../jira/derived/derive"; +import { useJiraStateValue } from "../../jira/history/hooks"; import { jStat } from 'jstat'; @@ -74,6 +75,7 @@ function getTeamNames(issues: Array) { const ConfigurationPanel: FC<{primaryIssuesOrReleasesObs: CanObservable>}> = ({ primaryIssuesOrReleasesObs }) => { const issues = useCanObservable(primaryIssuesOrReleasesObs); + const [jql] = useJiraStateValue('jql'); if(!issues?.length) { return
Loading ...
} @@ -82,7 +84,7 @@ const ConfigurationPanel: FC<{primaryIssuesOrReleasesObs: CanObservable -
{issues.length} items
+
{issues.length} items for JQL: {jql}
diff --git a/public/reports/table-grid.js b/public/reports/table-grid.js index efcc584f..80cf35a8 100644 --- a/public/reports/table-grid.js +++ b/public/reports/table-grid.js @@ -337,15 +337,23 @@ export class TableGrid extends StacheElement { } connected() { if(FEATURE_HISTORICALLY_ADJUSTED_ESTIMATES()) { - createRoot(document.getElementById("stats")).render( + let root = this.statsRoot; + if (!root) { + root = this.statsRoot = createRoot(document.getElementById("stats")); + } + root.render( createElement(Stats, { primaryIssuesOrReleasesObs: value.from(this,"primaryIssuesOrReleases") }) ); } - -} - + } + disconnected() { + if (this.statsRoot) { + this.statsRoot.unmount(); + this.statsRoot = null; + } + } } customElements.define("table-grid", TableGrid); diff --git a/public/shared/main-helper.js b/public/shared/main-helper.js index dbebfbc4..a38ce466 100644 --- a/public/shared/main-helper.js +++ b/public/shared/main-helper.js @@ -34,7 +34,7 @@ export default async function mainHelper(config, { host, createStorage }) { let fix = await legacyPrimaryReportingTypeRoutingFix(); fix = await legacyPrimaryIssueTypeRoutingFix(); - route.start(); + // route.start(); console.log("Loaded version of the Timeline Reporter: " + config?.COMMIT_SHA); diff --git a/public/shared/route-pushstate.ts b/public/shared/route-pushstate.ts new file mode 100644 index 00000000..db8f3d79 --- /dev/null +++ b/public/shared/route-pushstate.ts @@ -0,0 +1,54 @@ +import { deparam, ObservableObject, param, route, RoutePushstate } from "../can.js"; + +export const underlyingReplaceState = history.replaceState; + +export const pushStateObservable = new RoutePushstate(); + +route.urlData = pushStateObservable; +route.urlData.root = window.location.pathname; +// @ts-expect-error +route.register('/'); +// @ts-expect-error +route.start(); + +const keyObservable = new (ObservableObject as ObjectConstructor)(); + +// // @ts-expect-error +// keyObservable[Symbol.for('can.onPatches')]( +// function() { +// // @ts-expect-error +// this.get(); + +// // @ts-expect-error +// pushStateObservable.value = `?${param(this.get())}`; +// } +// ); +// // @ts-expect-error +// route.on('url', () => { +// // @ts-expect-error +// keyObservable.set(deparam(pushStateObservable.value)); +// }); + +const proxiedRouteData = new Proxy( + route.data, + { + get(target, p) { + if (p === 'set') { + return function(...args: [string, any] | [any]) { + const value = args.pop(); + const key = args[0]; + + if (!key && value && typeof value === 'object') { + const newValue = Object.entries(value).reduce((a, [key, val]) => ({ ...a, [key]: val ?? undefined }), {}); + target.set.call(newValue); + } else if (key) { + target.set.call(target, key, value ?? undefined); + } + }; + } else { + return target[p]; + } + }, +}); + +export default proxiedRouteData; \ No newline at end of file diff --git a/public/shared/state-storage.js b/public/shared/state-storage.js index 896de032..0bf5f8e0 100644 --- a/public/shared/state-storage.js +++ b/public/shared/state-storage.js @@ -1,4 +1,6 @@ -import { RoutePushstate, route, diff } from "../can.js"; +import { route, diff } from "../can.js"; + +import routeObservable, { underlyingReplaceState, pushStateObservable } from "@routing-observable"; export function saveToLocalStorage(key, defaultValue) { return { @@ -13,13 +15,7 @@ export function saveToLocalStorage(key, defaultValue) { } } -const underlyingReplaceState = history.replaceState; - - -export const pushStateObservable = new RoutePushstate(); -route.urlData = new RoutePushstate(); -route.urlData.root = window.location.pathname; - +export { routeObservable as pushStateObservable }; const dateMatch = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; @@ -102,9 +98,10 @@ export function updateUrlParam(key, valueJSON, defaultJSON) { const newUrl = new URL(window.location); if(valueJSON !== defaultJSON) { newUrl.searchParams.set(key, valueJSON ); + routeObservable.set(key, valueJSON ); } else { newUrl.searchParams.delete(key ); + routeObservable.set(key, null); } - pushStateObservable.value = newUrl.search; //history.pushState({}, '', ); } \ No newline at end of file diff --git a/rollup.config.mjs b/rollup.config.mjs index 968e2167..999ddc74 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -3,11 +3,13 @@ import { nodeResolve } from "@rollup/plugin-node-resolve"; import commonjs from "@rollup/plugin-commonjs"; import typescript from "rollup-plugin-typescript2"; import babel from "@rollup/plugin-babel"; +import alias from "@rollup/plugin-alias"; const babelProd = { exclude: "node_modules/**", plugins: ["@babel/plugin-transform-react-jsx"], babelHelpers: "bundled", + }; const warn = { @@ -21,6 +23,19 @@ const warn = { }, }; +const aliases = { + hosted: { + entries: { + '@routing-observable': `${import.meta.dirname}/public/shared/route-pushstate`, + }, + }, + connect: { + entries: { + "@routing-observable": `${import.meta.dirname}/public/jira/history/observable`, + }, + }, +}; + export default [ { input: "./public/oauth-callback.js", @@ -46,6 +61,7 @@ export default [ plugins: ["@babel/plugin-transform-react-jsx-development"], babelHelpers: "bundled", }), + alias(aliases.hosted), ], ...warn, }, @@ -55,7 +71,7 @@ export default [ file: "./public/dist/hosted-main.min.js", format: "esm", }, - plugins: [nodeResolve(), commonjs(), terser(), typescript(), babel(babelProd)], + plugins: [nodeResolve(), commonjs(), terser(), typescript(), babel(babelProd), alias(aliases.hosted)], ...warn, }, { @@ -64,7 +80,7 @@ export default [ file: "./public/dist/connect-main.min.js", format: "esm", }, - plugins: [nodeResolve(), commonjs(), terser(), typescript(), babel(babelProd)], + plugins: [nodeResolve(), commonjs(), terser(), typescript(), babel(babelProd), alias(aliases.connect)], ...warn, }, ]; diff --git a/scripts/atlassian-connect/index.ts b/scripts/atlassian-connect/index.ts index b7529a3d..23192dcb 100644 --- a/scripts/atlassian-connect/index.ts +++ b/scripts/atlassian-connect/index.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import path from "node:path"; const connectMetadata = { - local: { name: "Timeline Report (Local)", baseUrl: "", key: "bitovi.timeline-report.local" }, + local: { name: "Timeline Report (Local, Brad)", baseUrl: "https://0342-68-187-209-164.ngrok-free.app", key: "bitovi.timeline-report.local.brad" }, staging: { name: "Timeline Report (Staging)", baseUrl: "https://timeline-report-staging.bitovi-jira.com", @@ -57,7 +57,7 @@ function main() { main(); -function createModules({ name }: Metadata) { +function createModules({ name, key }: Metadata) { return { modules: { generalPages: [ @@ -70,7 +70,7 @@ function createModules({ name }: Metadata) { }, }, { - url: "/connect?primaryIssueType={ac.primaryIssueType}&hideUnknownInitiatives={ac.hideUnknownInitiatives}&jql={ac.jql}&loadChildren={ac.loadChildren}&primaryReportType={ac.primaryReportType}&secondaryReportType={ac.secondaryReportType}&showPercentComplete={ac.showPercentComplete}&showOnlySemverReleases={ac.showOnlySemverReleases}", + url: `/connect?primaryIssueType={ac.${key}.primaryIssueType}&hideUnknownInitiatives={ac.${key}.hideUnknownInitiatives}&jql={ac.${key}.jql}&loadChildren={ac.${key}.loadChildren}&primaryReportType={ac.${key}.primaryReportType}&secondaryReportType={ac.${key}.secondaryReportType}&showPercentComplete={ac.${key}.showPercentComplete}&showOnlySemverReleases={ac.${key}.showOnlySemverReleases}`, key: "deeplink", location: "none", name: { From 2c4cf24b7f49b4c4441c0a7b525703de934b48c8 Mon Sep 17 00:00:00 2001 From: Bradley Momberger Date: Wed, 11 Dec 2024 14:28:29 -0500 Subject: [PATCH 2/6] [TR-79] make the AP history observable work as closely as possible to can-route's data and urlData observables --- public/jira/history/observable.ts | 181 ++++++++++++++++++------------ public/shared/state-storage.js | 1 + 2 files changed, 109 insertions(+), 73 deletions(-) diff --git a/public/jira/history/observable.ts b/public/jira/history/observable.ts index 653c881b..8cf947de 100644 --- a/public/jira/history/observable.ts +++ b/public/jira/history/observable.ts @@ -1,16 +1,17 @@ +import { deparam as _deparam } from "../../can"; +const deparam = _deparam as (params?: string | null) => Record; + interface JiraLocationState { key: string; hash: string | null; - query?: Record | null; + query?: { + state?: string; + }; state?: Record | null; title: string; href: string; } -interface JiraPushLocationState extends Omit { - query?: Record | null; -} - type CanPatch = { type: 'add' | 'set'; key: string; @@ -27,7 +28,7 @@ declare global { type?: T, callback?: (state: JiraLocationState) => void ) => (T extends 'all' ? JiraLocationState : string); - replaceState: (state: Partial) => void; + replaceState: (state: Partial) => void; subscribeState: (type: string, callback: (state: JiraLocationState) => void) => void; } } @@ -55,16 +56,22 @@ export interface JiraStateSync { [isValueLikeSymbol]: true, on: (key: string | undefined, handler: KeyHandler, queue?: string) => void; off: (key: string | undefined, handler: KeyHandler, queue?: string) => void; - get: (key: T) => T extends string ? (string | undefined) : Record; - set: (...args: [string, string | null] | [state: Record]) => void; + get: (key: T) => T extends string ? (string | undefined) : Record; + set: (...args: [string] | [string, string | null] | [state: Record]) => void; + value: string; } const handlers = new Map>(); const patchHandlers = new Map(); const valueHandlers = new Set(); let lastQuery: Record | null = null; +let disablePushState = false; -const stateApi: Pick = { +const searchParamsToObject = (params: URLSearchParams) => ( + Array.from(params.entries()).reduce((a, [key, val]) => ({ ...a, [key]: val }), {}) +); + +const stateApi: Pick = { on: function(key, handler, queue) { if (!key) { valueHandlers.add(handler); @@ -94,38 +101,65 @@ const stateApi: Pick = { } }, get: function(key?: string) { - const encodedVal = decodeQuery(AP?.history.getState('all').query); + const params = new URLSearchParams(decodeURIComponent(AP?.history.getState('all').query?.state ?? '')); if (arguments.length > 0) { - return encodedVal?.[key!]; + return params.get(key!); } else { - return encodedVal ?? {}; + return searchParamsToObject(params); } } as JiraStateSync['get'], set(...args) { const [keyOrState, val] = args; - const { query: currentState = {} } = AP?.history.getState('all') ?? {}; - let newState: Record; + const { query } = AP?.history.getState('all') ?? {}; + const { state: currentState = '' } = query ?? {}; + const currentParams = deparam(decodeURIComponent(currentState)); + let newParams: Record = { ...currentParams }; + let newState: string; + if (typeof keyOrState === 'string') { - newState = { - ...currentState, - [keyOrState]: val ? encodeURIComponent(val) : null, - }; + if (args.length === 1) { + // urlData form, assume that the set() is for the full URL string. + newParams = deparam(keyOrState) as Record; + } else if (val == null) { + delete newParams[keyOrState]; + } else { + newParams[keyOrState] = val; + } } else { - const changedState = encodeQuery(keyOrState); - - newState = { - ...currentState, - ...changedState as Record, - }; + Object.entries(keyOrState).forEach(([key, val]) => { + if(val == null) { + delete newParams[key]; + } else { + newParams[key] = val; + } + }); } - if (Object.keys(newState).reduce((a, b) => a && (newState[b] === currentState?.[b]), true)) { + if ( + Object.keys(newParams) + .concat(Object.keys(currentParams)) + .reduce((a, b) => a && (newParams[b] === currentParams?.[b]), true) + ) { // no keys have changed. // Do not dispatch a new state to the AP history return; } + const newURLParams = new URLSearchParams(newParams); + newURLParams.sort(); - AP?.history.replaceState({ query: newState }) - } + AP?.history.replaceState({ + query: { state: encodeURIComponent(newURLParams.toString()) }, + state: { fromPopState: 'false' } + }) + }, + get value() { + const params = this.get(undefined); + const searchString = new URLSearchParams(params); + searchString.sort(); + return searchString.toString(); + }, + set value(params: string) { + this.set(params); + } } const dispatchKeyHandlers: (key: string, newValue?: string | null, oldValue?: string | null) => void = (key, newValue, oldValue) => { @@ -137,21 +171,6 @@ const dispatchKeyHandlers: (key: string, newValue?: string | null, oldValue?: st } } -const decodeQuery = ( - query?: Record | null -): Record => - Object.entries(query ?? {}).reduce( - (a, [key, val]) => ({ ...a, [key]: val ? decodeURIComponent(val) : val }), - {} - ); -const encodeQuery = ( - query?: Record | null -) => - Object.entries(query ?? {}).reduce( - (a, [key, val]) => ({ ...a, [key]: val ? encodeURIComponent(val) : null }), - {} - ) as Record; - export const browserState: JiraStateSync = { ...stateApi, [onKeyValueSymbol]: stateApi.on, @@ -164,30 +183,39 @@ export const browserState: JiraStateSync = { [isMapLikeSymbol]: true, }; -AP?.history?.subscribeState("change", ({ query }: { query?: Record | null; }) => { +AP?.history?.subscribeState("change", ({ query, state }: JiraLocationState) => { if (!lastQuery && !query) { return; } + const decodedQuery = decodeURIComponent(query?.state ?? ''); + const queryParams = deparam(decodedQuery); - const decodedQuery = decodeQuery(query); + // if we popped the state to get the new state, don't push yet another new state on the history + disablePushState = state?.fromPopState === 'true'; - if (query) { - Object.entries(decodedQuery).forEach(([key, val]) => { + if (query?.state) { + Object.entries(queryParams).forEach(([key, val]) => { if (val !== lastQuery?.[key]) { dispatchKeyHandlers(key, val, lastQuery?.[key]); } }); + } else { + Object.entries(lastQuery ?? {}).forEach(([key, lastVal]) => { + if (lastVal !== undefined) { + dispatchKeyHandlers(key, undefined, lastVal); + } + }); } if (patchHandlers.size > 0) { const patches = []; if (lastQuery == null) { - const newEntries = Object.entries(decodedQuery); + const newEntries = Object.entries(queryParams); patches.push(...newEntries.map(([key, val]) => ({ key, type: 'add', value: val }))); } else if (query == null) { const oldEntries = Object.entries(lastQuery); patches.push(...oldEntries.map(([key]) => ({ key, type: 'delete' }))); } else { - const newKeys = Object.keys(decodedQuery); + const newKeys = Object.keys(queryParams); const oldKeys = Object.keys(lastQuery); const adds = newKeys.filter(key => !oldKeys.includes(key)); const dels = oldKeys.filter(key => !newKeys.includes(key)); @@ -195,26 +223,39 @@ AP?.history?.subscribeState("change", ({ query }: { query?: Record ({ type: 'delete', key })), - ...sets.filter(key => query[key] !== lastQuery?.[key]).map(key => ({ type: 'set', key, value: decodedQuery[key] })), - ...adds.map(key => ({ type: 'add', key, value: decodedQuery[key] })), + ...sets.filter(key => queryParams[key] !== lastQuery?.[key]).map(key => ({ type: 'set', key, value: queryParams[key] })), + ...adds.map(key => ({ type: 'add', key, value: queryParams[key] })), ); } for(const handler of patchHandlers.values()) { handler(patches, undefined); }; + + disablePushState = false; } // Value handlers are dispatched after patch handlers // because the local URL is being updated by a patch // handler, while the observables looking at the URL // are using listenTo() which relies on value handlers + const lastQueryParams = new URLSearchParams(lastQuery ?? {}); + lastQueryParams.sort(); + const lastQueryString = lastQueryParams.toString(); valueHandlers.forEach(handler => { - handler(query ? decodedQuery : query, lastQuery); + handler(decodedQuery, lastQueryParams); }); - lastQuery = query ?? null; + lastQuery = decodedQuery ? queryParams : null; }); export default browserState; +const keyblocklist = [ + "xdm_e", + "xdm_c", + "cp", + "xdm_deprecated_addon_key_do_not_use", + "lic", + "cv", +]; // This listener keeps the frame URL search in sync with // the outer page's `ac` params, allowing our existing // observables to continue using the interior URL for @@ -229,36 +270,30 @@ browserState.on('can.patches', (patches: CanPatch[]) => { currentParams.set(patch.key, patch.value); } }) + currentParams.sort(); + const paramString = currentParams.toString(); + keyblocklist.forEach(key => currentParams.delete(key)); - history.pushState({}, '', `?${currentParams.toString()}`); + if (!disablePushState) { + history.pushState(searchParamsToObject(currentParams), '', `?${paramString}`); + } }); -const keyblocklist = [ - "xdm_e", - "xdm_c", - "cp", - "xdm_deprecated_addon_key_do_not_use", - "lic", - "cv", -]; window.addEventListener('popstate', () => { const { search } = window.location; - const searchState = encodeQuery( - Array.from(new URLSearchParams(search).entries()).filter(([key]) => !keyblocklist.includes(key)).reduce( - (a, [key, val]) => ({ ...a, [key]: val }) - , {}) - ); - AP?.history.replaceState(searchState); + const searchParams = new URLSearchParams(search); + searchParams.sort(); + keyblocklist.forEach(key => searchParams.delete(key)); + + AP?.history.replaceState({ query: { state: encodeURIComponent(searchParams.toString()) }, state: { fromPopState: 'true' }}); }); AP?.history?.getState('all', ({ query }) => { - const decodedQuery = decodeQuery(query); - const currentParams = new URLSearchParams(location.search); - Object.entries(decodedQuery).forEach(([key, val]) => { - currentParams.set(key, val!); + const newState = decodeURIComponent(query?.state ?? '') + history.pushState(deparam(newState), '', `?${newState}`); + valueHandlers.forEach(handler => { + handler(newState, undefined); }); - - history.replaceState({}, '', `?${currentParams.toString()}`); }); export const underlyingReplaceState = history.replaceState; diff --git a/public/shared/state-storage.js b/public/shared/state-storage.js index 0bf5f8e0..0cbf42f5 100644 --- a/public/shared/state-storage.js +++ b/public/shared/state-storage.js @@ -103,5 +103,6 @@ export function updateUrlParam(key, valueJSON, defaultJSON) { newUrl.searchParams.delete(key ); routeObservable.set(key, null); } + pushStateObservable.value = newUrl.search; //history.pushState({}, '', ); } \ No newline at end of file From faddab828f1dac98b9afbbfefd66fb03d5bf748a Mon Sep 17 00:00:00 2001 From: Bradley Momberger Date: Wed, 11 Dec 2024 18:31:03 -0500 Subject: [PATCH 3/6] [TR-79] Fix value getter/setter for urlData-like mode; fix types for get() --- public/jira/history/hooks.ts | 18 +++++++++--------- public/jira/history/observable.ts | 28 +++++++++++++++++----------- public/shared/state-storage.js | 2 -- rollup.config.mjs | 6 +++--- tsconfig.json | 4 ++++ 5 files changed, 33 insertions(+), 25 deletions(-) diff --git a/public/jira/history/hooks.ts b/public/jira/history/hooks.ts index e615fce9..ff9ab1d7 100644 --- a/public/jira/history/hooks.ts +++ b/public/jira/history/hooks.ts @@ -1,20 +1,20 @@ import { useEffect, useState } from 'react'; -import { pushStateObservable } from '../../shared/state-storage'; +import routeDataObservable from '@routing-observable'; export const useJiraStateValue: (key: string) => [string | undefined, (val: string | undefined) => void] = (key) => { - const [value, setValue] = useState(pushStateObservable.get(key)); + const [value, setValue] = useState(routeDataObservable.get(key)); useEffect(() => { const handler = (newVal?: string) => { setValue(newVal); }; - pushStateObservable.on(key, handler); + routeDataObservable.on(key, handler); - const setValue = (val: string | undefined) => pushStateObservable.set(key, val); + const setValue = (val: string | undefined) => routeDataObservable.set(key, val ?? null); - return () => pushStateObservable.off(key, handler); + return () => routeDataObservable.off(key, handler); }, []); return [value, setValue]; @@ -23,17 +23,17 @@ export const useJiraStateValue: export const useJiraState: () => [Record, (val: Record) => void] = () => { - const [value, setValue] = useState>((pushStateObservable.get(undefined) ?? {})); + const [value, setValue] = useState>((routeDataObservable.get() ?? {})); useEffect(() => { const handler = (newVal: Record) => { setValue(newVal ?? {}); }; - pushStateObservable.on(undefined, handler); + routeDataObservable.on(undefined, handler); - const setValue = (val: Record) => pushStateObservable.set(val); + const setValue = (val: Record) => routeDataObservable.set(val); - return () => pushStateObservable.off(undefined, handler); + return () => routeDataObservable.off(undefined, handler); }, []); return [value, setValue]; diff --git a/public/jira/history/observable.ts b/public/jira/history/observable.ts index 8cf947de..2f405a74 100644 --- a/public/jira/history/observable.ts +++ b/public/jira/history/observable.ts @@ -56,11 +56,17 @@ export interface JiraStateSync { [isValueLikeSymbol]: true, on: (key: string | undefined, handler: KeyHandler, queue?: string) => void; off: (key: string | undefined, handler: KeyHandler, queue?: string) => void; - get: (key: T) => T extends string ? (string | undefined) : Record; + get: ObjectGetter; set: (...args: [string] | [string, string | null] | [state: Record]) => void; value: string; } +interface ObjectGetter { + (): Record; + (key: undefined): Record; + (key: string): (string | undefined); +} + const handlers = new Map>(); const patchHandlers = new Map(); const valueHandlers = new Set(); @@ -71,7 +77,7 @@ const searchParamsToObject = (params: URLSearchParams) => ( Array.from(params.entries()).reduce((a, [key, val]) => ({ ...a, [key]: val }), {}) ); -const stateApi: Pick = { +const stateApi: Pick = { on: function(key, handler, queue) { if (!key) { valueHandlers.add(handler); @@ -151,15 +157,6 @@ const stateApi: Pick = { state: { fromPopState: 'false' } }) }, - get value() { - const params = this.get(undefined); - const searchString = new URLSearchParams(params); - searchString.sort(); - return searchString.toString(); - }, - set value(params: string) { - this.set(params); - } } const dispatchKeyHandlers: (key: string, newValue?: string | null, oldValue?: string | null) => void = (key, newValue, oldValue) => { @@ -181,6 +178,15 @@ export const browserState: JiraStateSync = { [setKeyValueSymbol]: stateApi.set as JiraStateSync[typeof setKeyValueSymbol], [isValueLikeSymbol]: true, [isMapLikeSymbol]: true, + get value() { + const params = this.get(undefined); + const searchString = new URLSearchParams(params); + searchString.sort(); + return searchString.toString(); + }, + set value(params: string) { + this.set(params); + }, }; AP?.history?.subscribeState("change", ({ query, state }: JiraLocationState) => { diff --git a/public/shared/state-storage.js b/public/shared/state-storage.js index 0cbf42f5..323ca195 100644 --- a/public/shared/state-storage.js +++ b/public/shared/state-storage.js @@ -98,10 +98,8 @@ export function updateUrlParam(key, valueJSON, defaultJSON) { const newUrl = new URL(window.location); if(valueJSON !== defaultJSON) { newUrl.searchParams.set(key, valueJSON ); - routeObservable.set(key, valueJSON ); } else { newUrl.searchParams.delete(key ); - routeObservable.set(key, null); } pushStateObservable.value = newUrl.search; //history.pushState({}, '', ); diff --git a/rollup.config.mjs b/rollup.config.mjs index 999ddc74..a40bb952 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -55,13 +55,13 @@ export default [ plugins: [ nodeResolve(), commonjs(), + alias(aliases.hosted), typescript(), babel({ exclude: "node_modules/**", plugins: ["@babel/plugin-transform-react-jsx-development"], babelHelpers: "bundled", }), - alias(aliases.hosted), ], ...warn, }, @@ -71,7 +71,7 @@ export default [ file: "./public/dist/hosted-main.min.js", format: "esm", }, - plugins: [nodeResolve(), commonjs(), terser(), typescript(), babel(babelProd), alias(aliases.hosted)], + plugins: [nodeResolve(), commonjs(), terser(), , alias(aliases.hosted), typescript(), babel(babelProd)], ...warn, }, { @@ -80,7 +80,7 @@ export default [ file: "./public/dist/connect-main.min.js", format: "esm", }, - plugins: [nodeResolve(), commonjs(), terser(), typescript(), babel(babelProd), alias(aliases.connect)], + plugins: [nodeResolve(), commonjs(), terser(), alias(aliases.connect), typescript(), babel(babelProd)], ...warn, }, ]; diff --git a/tsconfig.json b/tsconfig.json index 090b96ed..cefc44f7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,6 +17,10 @@ "paths": { "undici-types": [ "./node_modules/undici/types" + ], + "@routing-observable": [ + "./public/jira/history/observable", + "./public/shared/route-pushstate" ] }, "sourceMap": false From d0a54595f6b75c1e8f003a99a60932e424b02303 Mon Sep 17 00:00:00 2001 From: Bradley Momberger Date: Wed, 18 Dec 2024 18:42:41 -0500 Subject: [PATCH 4/6] [TR-79] Integrate all React hooks and components with universal routing API --- public/jira/history/components.ts | 48 ++++++ public/jira/history/hooks.ts | 47 +++++- public/jira/history/observable.ts | 159 ++++++++++++++---- public/react/SaveReports/SaveReports.tsx | 36 ++-- .../SaveReports/SaveReportsWrapper.test.tsx | 9 +- .../SavedReportDropdown/RecentReports.tsx | 6 + .../useSelectedReports/useSelectedReport.ts | 33 ++-- public/react/Stats/Stats.tsx | 2 +- public/react/ViewReports/ViewReport.test.tsx | 6 +- public/react/ViewReports/ViewReports.tsx | 22 +-- .../react/ViewReports/ViewReportsWrapper.tsx | 8 +- public/react/hooks/useQueryParams/index.ts | 1 - .../hooks/useQueryParams/useQueryParams.ts | 19 --- rollup.config.mjs | 12 +- 14 files changed, 281 insertions(+), 127 deletions(-) create mode 100644 public/jira/history/components.ts delete mode 100644 public/react/hooks/useQueryParams/index.ts delete mode 100644 public/react/hooks/useQueryParams/useQueryParams.ts diff --git a/public/jira/history/components.ts b/public/jira/history/components.ts new file mode 100644 index 00000000..389dbed7 --- /dev/null +++ b/public/jira/history/components.ts @@ -0,0 +1,48 @@ +import { MouseEvent, PropsWithChildren, default as React } from 'react'; + +import routeDataObservable from '@routing-observable'; +import { deparam, param } from '../../can'; + +interface RoutingLinkPropsBase extends PropsWithChildren { + as?: 'a' | 'button'; + replaceAll?: boolean; + className?: string; +} +type RoutingLinkProps = RoutingLinkPropsBase & ( + { data: Record; href?: string; } | + { href: string; data?: Record; } +) + + export const RoutingLink = ({ + as = 'a', + replaceAll, + children, + className, + ...rest +}: RoutingLinkProps) => { + let href; + if (rest.href) { + href = new URL(rest.href, document.baseURI).search; + } + else { + href = param(rest.data); + } + return React.createElement(as, { + ...(as === 'a' ? { href } : {}), + onClick: (ev: MouseEvent) => { + ev.preventDefault(); + + const patchSet = rest.data ? { ...rest.data } : (deparam(href) as Record); + if(replaceAll) { + Object.keys(routeDataObservable.get()).forEach(key => { + if(!patchSet[key]) { + patchSet[key] = null; + } + }); + } + routeDataObservable.set(patchSet); + }, + children, + className, + }); +}; diff --git a/public/jira/history/hooks.ts b/public/jira/history/hooks.ts index 95e198dc..d696ced4 100644 --- a/public/jira/history/hooks.ts +++ b/public/jira/history/hooks.ts @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react'; -import routeDataObservable from '@routing-observable'; +import routeDataObservable, { pushStateObservable } from '@routing-observable'; export const useHistoryStateValue: (key: string) => [string | undefined, (val: string | undefined) => void] = @@ -12,12 +12,11 @@ export const useHistoryStateValue: routeDataObservable.on(key, handler); - const setValue = (val: string | undefined) => routeDataObservable.set(key, val ?? null); - return () => routeDataObservable.off(key, handler); }, []); - return [value, setValue]; + const exportSetValue = (val: string | undefined) => routeDataObservable.set(key, val ?? null); + return [value, exportSetValue]; }; export const useHistoryState: @@ -31,11 +30,47 @@ export const useHistoryState: routeDataObservable.on(undefined, handler); - const setValue = (val: Record) => routeDataObservable.set(val); - return () => routeDataObservable.off(undefined, handler); }, []); + const exportSetValue = (val: Record) => routeDataObservable.set(val); + return [value, exportSetValue]; + }; + +export const useHistoryParams: + () => [string, (val: string) => void] = + () => { + const [value, setValue] = useState(pushStateObservable.value); + useEffect(() => { + const handler = () => { + setValue(pushStateObservable.value); + }; + + routeDataObservable.on(handler); + + return () => routeDataObservable.off(handler); + }, []); + + const exportSetValue = (val: string) => pushStateObservable.set(val); return [value, setValue]; }; + +export const useHistoryValueCallback: + (key: string, callback: (newVal: string | undefined) => void) => void = + (key, callback) => { + useEffect(() => { + routeDataObservable.on(key, callback); + return () => routeDataObservable.off(key, callback); + }, [key, callback]); + }; + +export const useHistoryCallback: + (callback: (newVal: Record) => void) => void = + (callback) => { + useEffect(() => { + routeDataObservable.on(undefined, callback); + return () => routeDataObservable.off(undefined, callback); + }, [callback]); + }; + diff --git a/public/jira/history/observable.ts b/public/jira/history/observable.ts index fdab670b..03f234ba 100644 --- a/public/jira/history/observable.ts +++ b/public/jira/history/observable.ts @@ -82,7 +82,26 @@ const searchParamsToObject = (params: URLSearchParams) => ( Array.from(params.entries()).reduce((a, [key, val]) => ({ ...a, [key]: val }), {}) ); +const keyblocklist = [ + "xdm_e", + "xdm_c", + "cp", + "xdm_deprecated_addon_key_do_not_use", + "lic", + "cv", +]; + const stateApi: Pick = { +/** + * on(key, handler): fire `handler` when `key` changes on the history state. + * handler receives the new string or undefined value and the previous string or undefined value + * on(handler) or on(undefined, handler): fire `handler` when any property changes in history state + * handler receives the new state and the previous state + * on('can.patches', handler): fire 'handler' when any property changes in the history state + * handler receives an array of CanPatch objects describing changes to the state. + * + * `queue` is not used and is only preserved for CanJS compatibility + */ on: function(key, handler, queue) { if (typeof key === 'function' && typeof handler !== 'function') { queue = handler; @@ -107,6 +126,13 @@ const stateApi: Pick = { handlers.set(key, handlerSet); } } as JiraStateSync['on'], +/** + * off(key, handler): remove handler from dispatch targets for key + * off(handler) or off(undefined, handler): remove handler from dispatch targets for full history state + * of('can.patches', handler): remove handler from dispatch targets for 'can.patches' + * + * `queue` is not used and is only preserved for CanJS compatibility + */ off: function(key, handler, queue) { if (typeof key === 'function' && typeof handler !== 'function') { queue = handler; @@ -122,6 +148,10 @@ const stateApi: Pick = { keyHandlers.delete(handler); } } as JiraStateSync['off'], + /** + * get(key): return the possibly undefined string value of `key` on the state + * get() or get(undefined): return the current history state as a Record of string values + */ get: function(key?: string) { const params = new URLSearchParams(decodeURIComponent(AP?.history.getState('all').query?.state ?? '')); if (arguments.length > 0) { @@ -130,6 +160,14 @@ const stateApi: Pick = { return searchParamsToObject(params); } } as JiraStateSync['get'], + /** + * set(key, value): set the property `key` to `value` on the state + * set(value: Object): set the history state to `value`. Do not preserve any previous property values + * set(params: string): set the history state to the deparameterized form of `value`. Do not preserve + * any previous property values. + * + * If the new state is the same as the previous one, no changes will be dispatched. + */ set(...args) { const [keyOrState, val] = args; const { query } = AP?.history.getState('all') ?? {}; @@ -156,6 +194,10 @@ const stateApi: Pick = { } }); } + keyblocklist.forEach(key => { + delete newParams[key]; + }); + if ( Object.keys(newParams) .concat(Object.keys(currentParams)) @@ -184,8 +226,42 @@ const dispatchKeyHandlers: (key: string, newValue?: string | null, oldValue?: st } } +/** + * construct and dispatch "patches", which detail the changes made from the + * previous state to the current one. + */ +const dispatchPatchHandlers = (queryParams: Record, lastQuery: Record | null) => { + if (patchHandlers.size > 0) { + const patches = []; + if (lastQuery == null) { + const newEntries = Object.entries(queryParams); + patches.push(...newEntries.map(([key, val]) => ({ key, type: 'add', value: val }))); + } else if (Object.keys(queryParams).length < 1) { + const oldEntries = Object.entries(lastQuery); + patches.push(...oldEntries.map(([key]) => ({ key, type: 'delete' }))); + } else { + const newKeys = Object.keys(queryParams); + const oldKeys = Object.keys(lastQuery); + const adds = newKeys.filter(key => !oldKeys.includes(key)); + const dels = oldKeys.filter(key => !newKeys.includes(key)); + const sets = newKeys.filter(key => !adds.includes(key)); + + patches.push( + ...dels.map(key => ({ type: 'delete', key })), + ...sets.filter(key => queryParams[key] !== lastQuery?.[key]).map(key => ({ type: 'set', key, value: queryParams[key] })), + ...adds.map(key => ({ type: 'add', key, value: queryParams[key] })), + ); + } + for(const handler of patchHandlers.values()) { + handler(patches, undefined); + }; + + } +} + export const browserState: JiraStateSync = { ...stateApi, + // CanJS reflection symbols let this observable be used with `listenTo` in value resolvers. [onKeyValueSymbol]: stateApi.on, [offKeyValueSymbol]: stateApi.off, [onPatchesSymbol]: stateApi.on.bind(null, 'can.patches'), @@ -194,8 +270,11 @@ export const browserState: JiraStateSync = { [setKeyValueSymbol]: stateApi.set as JiraStateSync[typeof setKeyValueSymbol], [isValueLikeSymbol]: true, [isMapLikeSymbol]: true, + // Special `value` getter/setter which lets the browser state object behave similar to + // a PushStateObservable. `value` resolves to the parameterized string form of the + // history state. get value() { - const params = this.get(undefined); + const params = this.get(); const searchString = new URLSearchParams(params); searchString.sort(); return searchString.toString(); @@ -205,6 +284,19 @@ export const browserState: JiraStateSync = { }, }; +/** + * Listen to the history state from Jira. "change" events will dispatch a JiraLocationState + * the same as when calling `AP.history.getState('all')`. This object contains information + * about the parent frame URL and the app frame's history properties. + + * We are interested in the query, which contains any query param in the parent frame prefixed with + * `ac.` and the app key. For convenience this app only uses one query param, `state`, which contains + * the full parameterized URL search params for the frame minus the ones set up by Jira itself to + * set up cross-frame messaging (see `keyblocklist` above). + * + * We also use `state` as in history state, but only to indicate that we should not push a new state + * onto the local history when the change occurs due to a popstate + */ AP?.history?.subscribeState("change", ({ query, state }: JiraLocationState) => { if (!lastQuery && !query) { return; @@ -228,33 +320,9 @@ AP?.history?.subscribeState("change", ({ query, state }: JiraLocationState) => { } }); } - if (patchHandlers.size > 0) { - const patches = []; - if (lastQuery == null) { - const newEntries = Object.entries(queryParams); - patches.push(...newEntries.map(([key, val]) => ({ key, type: 'add', value: val }))); - } else if (query == null) { - const oldEntries = Object.entries(lastQuery); - patches.push(...oldEntries.map(([key]) => ({ key, type: 'delete' }))); - } else { - const newKeys = Object.keys(queryParams); - const oldKeys = Object.keys(lastQuery); - const adds = newKeys.filter(key => !oldKeys.includes(key)); - const dels = oldKeys.filter(key => !newKeys.includes(key)); - const sets = newKeys.filter(key => !adds.includes(key)); + dispatchPatchHandlers(queryParams, lastQuery); - patches.push( - ...dels.map(key => ({ type: 'delete', key })), - ...sets.filter(key => queryParams[key] !== lastQuery?.[key]).map(key => ({ type: 'set', key, value: queryParams[key] })), - ...adds.map(key => ({ type: 'add', key, value: queryParams[key] })), - ); - } - for(const handler of patchHandlers.values()) { - handler(patches, undefined); - }; - - disablePushState = false; - } + disablePushState = false; // Value handlers are dispatched after patch handlers // because the local URL is being updated by a patch // handler, while the observables looking at the URL @@ -269,15 +337,6 @@ AP?.history?.subscribeState("change", ({ query, state }: JiraLocationState) => { lastQuery = decodedQuery ? queryParams : null; }); export default browserState; - -const keyblocklist = [ - "xdm_e", - "xdm_c", - "cp", - "xdm_deprecated_addon_key_do_not_use", - "lic", - "cv", -]; // This listener keeps the frame URL search in sync with // the outer page's `ac` params, allowing our existing // observables to continue using the interior URL for @@ -301,6 +360,11 @@ browserState.on('can.patches', (patches: CanPatch[]) => { } }); +/** + * When the user moves back or forward in navigation, the app iframe's state updates. + * We send the change back up to the parent frame, which will then trigger the "change" + * event and fire any key or value event listeners bound to the history state. + */ window.addEventListener('popstate', () => { const { search } = window.location; const searchParams = new URLSearchParams(search); @@ -310,12 +374,33 @@ window.addEventListener('popstate', () => { AP?.history.replaceState({ query: { state: encodeURIComponent(searchParams.toString()) }, state: { fromPopState: 'true' }}); }); +/** + * At frame load, get the initial state from the history API and dispatch all changes. + * If any params have been set on the URL directly, i.e. from the Atlassian Connect app URL, + * add those to the initial state as well. + */ AP?.history?.getState('all', ({ query }) => { const newState = decodeURIComponent(query?.state ?? '') - history.pushState(deparam(newState), '', `?${newState}`); + const { search } = window.location; + const searchParams = new URLSearchParams(search); + const newStateParams = new URLSearchParams(newState); + newStateParams.forEach((val, key) => { + searchParams.set(key, val); + }); + searchParams.sort(); + keyblocklist.forEach(key => { + searchParams.delete(key); + }) + const combinedState = searchParamsToObject(searchParams); + + searchParams.forEach(([key, val]) => { + dispatchKeyHandlers(key, val, undefined); + }); + dispatchPatchHandlers(combinedState, null) valueHandlers.forEach(handler => { handler(newState, undefined); }); + lastQuery = combinedState; }); export const underlyingReplaceState = history.replaceState; diff --git a/public/react/SaveReports/SaveReports.tsx b/public/react/SaveReports/SaveReports.tsx index dece3f14..cd29f72c 100644 --- a/public/react/SaveReports/SaveReports.tsx +++ b/public/react/SaveReports/SaveReports.tsx @@ -11,16 +11,17 @@ import { useAllReports, useCreateReport, useRecentReports } from "../services/re import SaveReportModal from "./components/SaveReportModal"; import SavedReportDropdown from "./components/SavedReportDropdown"; import EditableTitle from "./components/EditableTitle"; -import { useQueryParams } from "../hooks/useQueryParams"; import { useSelectedReport } from "./hooks/useSelectedReports"; import LinkButton from "../components/LinkButton"; +import routeDataObservable, { pushStateObservable as queryParamObservable } from "@routing-observable"; +import { useHistoryState, useHistoryStateValue, useHistoryValueCallback } from "../../jira/history/hooks"; +import { param } from "../../can"; interface SaveReportProps { onViewReportsButtonClicked: () => void; - queryParamObservable: CanObservable; } -const SaveReport: FC = ({ queryParamObservable, onViewReportsButtonClicked }) => { +const SaveReport: FC = ({ onViewReportsButtonClicked }) => { const [isOpen, setIsOpen] = useState(false); const openModal = () => setIsOpen(true); const closeModal = () => setIsOpen(false); @@ -30,11 +31,12 @@ const SaveReport: FC = ({ queryParamObservable, onViewReportsBu const { createReport, isCreating } = useCreateReport(); const { selectedReport, updateSelectedReport, isDirty } = useSelectedReport({ reports, - queryParamObservable, }); const [name, setName] = useState(selectedReport?.name ?? "Untitled Report"); + const [jql] = useHistoryStateValue("jql"); + useEffect(() => { if (!selectedReport) { return; @@ -45,17 +47,15 @@ const SaveReport: FC = ({ queryParamObservable, onViewReportsBu const { recentReports, addReportToRecents } = useRecentReports(); - const { queryParams } = useQueryParams(queryParamObservable, { - onChange: (params) => { - const report = params.get("report"); + const [ queryParams ] = useHistoryState(); + useHistoryValueCallback("report", (report: string | undefined) => { - // TODO: If confirm `report` exists in `reports` before adding - // TODO: Reconcile deleted reports with whats there + // TODO: If confirm `report` exists in `reports` before adding + // TODO: Reconcile deleted reports with whats there - if (report) { - addReportToRecents(report); - } - }, + if (report) { + addReportToRecents(report); + } }); const validateName = (name: string) => { @@ -69,11 +69,13 @@ const SaveReport: FC = ({ queryParamObservable, onViewReportsBu const handleCreate = (name: string) => { const id = uuidv4(); - const params = new URLSearchParams(window.location.search); - params.set("report", id); + const params = { + ...routeDataObservable.get(), + report: id, + }; createReport( - { id, name, queryParams: params.toString() }, + { id, name, queryParams: param(params) }, { onSuccess: () => { closeModal(); @@ -135,7 +137,7 @@ const SaveReport: FC = ({ queryParamObservable, onViewReportsBu )}
- {!selectedReport && !!queryParams.get("jql") && ( + {!selectedReport && !!jql && ( diff --git a/public/react/SaveReports/SaveReportsWrapper.test.tsx b/public/react/SaveReports/SaveReportsWrapper.test.tsx index c4ad1aa3..583020dd 100644 --- a/public/react/SaveReports/SaveReportsWrapper.test.tsx +++ b/public/react/SaveReports/SaveReportsWrapper.test.tsx @@ -3,6 +3,7 @@ import { render, screen, waitFor } from "@testing-library/react"; import { describe, it, vi } from "vitest"; import SaveReportsWrapper from "./SaveReportsWrapper"; +import { pushStateObservable } from "@routing-observable"; const mockOnViewReportsButtonClicked = vi.fn(); @@ -16,7 +17,6 @@ describe("", () => { update: vi.fn(), }} onViewReportsButtonClicked={mockOnViewReportsButtonClicked} - queryParamObservable={{ on: vi.fn(), off: vi.fn(), value: "", set: vi.fn() }} /> ); @@ -28,6 +28,7 @@ describe("", () => { }); it("shows the create report button if jql is present", async () => { + pushStateObservable.set("?jql=issues-and-what-not"); render( ", () => { update: vi.fn(), }} onViewReportsButtonClicked={mockOnViewReportsButtonClicked} - queryParamObservable={{ - on: vi.fn(), - off: vi.fn(), - value: "?jql=issues-and-what-not", - set: vi.fn(), - }} /> ); diff --git a/public/react/SaveReports/components/SavedReportDropdown/RecentReports.tsx b/public/react/SaveReports/components/SavedReportDropdown/RecentReports.tsx index 25c80853..63a8a3b6 100644 --- a/public/react/SaveReports/components/SavedReportDropdown/RecentReports.tsx +++ b/public/react/SaveReports/components/SavedReportDropdown/RecentReports.tsx @@ -5,6 +5,7 @@ import React, { forwardRef } from "react"; import { DropdownItem, DropdownItemGroup } from "@atlaskit/dropdown-menu"; import Hr from "../../../components/Hr"; +import { pushStateObservable } from "@routing-observable"; interface RecentReportsProps { recentReports: string[]; @@ -65,6 +66,11 @@ const ReportListItem: FC = ({ reports, reportId }) => { // @ts-expect-error types for component overrides on ADS don't work component="a" href={"?" + matched.queryParams} + onClick={(ev) => { + ev.preventDefault(); + const { href } = (ev.target as HTMLElement).closest('a') as HTMLAnchorElement; + pushStateObservable.set(new URL(href).search) + }} > {matched.name} diff --git a/public/react/SaveReports/hooks/useSelectedReports/useSelectedReport.ts b/public/react/SaveReports/hooks/useSelectedReports/useSelectedReport.ts index cd41c13d..9b9cbf88 100644 --- a/public/react/SaveReports/hooks/useSelectedReports/useSelectedReport.ts +++ b/public/react/SaveReports/hooks/useSelectedReports/useSelectedReport.ts @@ -1,16 +1,16 @@ import type { Report, Reports } from "../../../../jira/reports"; import { useMemo, useState } from "react"; -import { useQueryParams } from "../../../hooks/useQueryParams"; import { CanObservable } from "../../../hooks/useCanObservable"; import { useUpdateReport } from "../../../services/reports"; import { getReportFromParams, paramsMatchReport } from "./utilities"; +import { useHistoryCallback, useHistoryParams, useHistoryState } from "../../../../jira/history/hooks"; +import routeDataObservable from "@routing-observable"; +import { param } from "../../../../can"; export const useSelectedReport = ({ reports, - queryParamObservable, }: { - queryParamObservable: CanObservable; reports: Reports; }) => { const { updateReport } = useUpdateReport(); @@ -18,20 +18,22 @@ export const useSelectedReport = ({ getReportFromParams(reports) ); + const [initial] = useHistoryState(); + + const [search] = useHistoryParams(); + const [isDirty, setIsDirty] = useState( - () => !paramsMatchReport(new URLSearchParams(window.location.search), reports) + () => !paramsMatchReport(new URLSearchParams(search), reports) ); - useQueryParams(queryParamObservable, { - onChange: (params) => { - const newSelectedReport = getReportFromParams(reports); + useHistoryCallback((params) => { + const newSelectedReport = getReportFromParams(reports); - if (newSelectedReport?.id !== selectedReport?.id) { - setSelectedReport(newSelectedReport); - } + if (newSelectedReport?.id !== selectedReport?.id) { + setSelectedReport(newSelectedReport); + } - setIsDirty(() => !paramsMatchReport(params, reports)); - }, + setIsDirty(() => !paramsMatchReport(new URLSearchParams(params), reports)); }); return { @@ -42,13 +44,12 @@ export const useSelectedReport = ({ return; } - const queryParams = new URLSearchParams(window.location.search); - - queryParams.delete("settings"); + const queryParams = routeDataObservable.get(); + delete queryParams.settings; updateReport( selectedReport.id, - { queryParams: queryParams.toString() }, + { queryParams: param(queryParams) }, { onSuccess: () => setIsDirty(false) } ); }, diff --git a/public/react/Stats/Stats.tsx b/public/react/Stats/Stats.tsx index efdadf58..bcae287e 100644 --- a/public/react/Stats/Stats.tsx +++ b/public/react/Stats/Stats.tsx @@ -84,7 +84,7 @@ const ConfigurationPanel: FC<{primaryIssuesOrReleasesObs: CanObservable -
{issues.length} items for JQL: {jql}
+
{issues.length} items
diff --git a/public/react/ViewReports/ViewReport.test.tsx b/public/react/ViewReports/ViewReport.test.tsx index b84246f9..f2d3ea6c 100644 --- a/public/react/ViewReports/ViewReport.test.tsx +++ b/public/react/ViewReports/ViewReport.test.tsx @@ -3,6 +3,7 @@ import { render, screen } from "@testing-library/react"; import { describe, it, vi, beforeEach, Mock } from "vitest"; import ViewReports from "./ViewReports"; import { useAllReports } from "../services/reports"; +import { pushStateObservable } from "@routing-observable"; vi.mock("../services/reports"); @@ -34,10 +35,7 @@ describe("ViewReports Component", () => { }); it("renders the selected report's name in the reportInfo section", () => { - Object.defineProperty(window, "location", { - writable: true, - value: { search: "?report=1" }, - }); + pushStateObservable.set("?report=1") render(); diff --git a/public/react/ViewReports/ViewReports.tsx b/public/react/ViewReports/ViewReports.tsx index 330410df..a438d344 100644 --- a/public/react/ViewReports/ViewReports.tsx +++ b/public/react/ViewReports/ViewReports.tsx @@ -5,6 +5,9 @@ import DynamicTable from "@atlaskit/dynamic-table"; import ViewReportsLayout from "./components/ViewReportsLayout"; import { useAllReports } from "../services/reports"; +import { RoutingLink } from "../../jira/history/components"; +import routeDataObservable from "@routing-observable"; +import { useHistoryStateValue } from "../../jira/history/hooks"; interface ViewReportProps { onBackButtonClicked: () => void; @@ -13,20 +16,18 @@ interface ViewReportProps { const ViewReports: FC = ({ onBackButtonClicked }) => { const reports = useAllReports(); - const selectedReport = useMemo(() => { - const params = new URLSearchParams(window.location.search); - const selectedReport = params.get("report"); - - if (!selectedReport) { + const [selectedReportId] = useHistoryStateValue("report"); + const selectedReportName = useMemo(() => { + if (!selectedReportId) { return ""; } return ( Object.values(reports) .filter((report) => !!report) - .find(({ id }) => id === selectedReport)?.name || "" + .find(({ id }) => id === selectedReportId)?.name || "" ); - }, [reports]); + }, [reports, selectedReportId]); const reportRows = Object.values(reports) .filter((r) => !!r) @@ -37,12 +38,13 @@ const ViewReports: FC = ({ onBackButtonClicked }) => { { key: `${report.id}-report`, content: ( - Report name {report.name} - + ), }, ], @@ -52,7 +54,7 @@ const ViewReports: FC = ({ onBackButtonClicked }) => { return ( {selectedReport}

: null} + reportInfo={selectedReportName ?

{selectedReportName}

: null} > = ({ onBackButtonClicked }) => { - const selectedReportExists = useMemo(() => { - const params = new URLSearchParams(window.location.search); - return !!params.get("report"); - }, []); + const [selectedReport] = useHistoryStateValue("report"); + const selectedReportExists = !!selectedReport; const rows = [...Array.from({ length: numberOfSkeletonRows }).keys()].map((i) => { return { diff --git a/public/react/hooks/useQueryParams/index.ts b/public/react/hooks/useQueryParams/index.ts deleted file mode 100644 index b3a7bd35..00000000 --- a/public/react/hooks/useQueryParams/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./useQueryParams"; diff --git a/public/react/hooks/useQueryParams/useQueryParams.ts b/public/react/hooks/useQueryParams/useQueryParams.ts deleted file mode 100644 index 0c4e8b5d..00000000 --- a/public/react/hooks/useQueryParams/useQueryParams.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { CanObservable } from "../useCanObservable"; - -import { useEffect } from "react"; -import { useCanObservable } from "../useCanObservable"; - -export const useQueryParams = ( - queryParamObservable: CanObservable, - config: { onChange: (params: URLSearchParams) => void } -) => { - const queryParamString = useCanObservable(queryParamObservable); - - useEffect(() => { - const params = new URLSearchParams(queryParamString); - - config.onChange(params); - }, [queryParamString]); - - return { queryParamString, queryParams: new URLSearchParams(queryParamString) }; -}; diff --git a/rollup.config.mjs b/rollup.config.mjs index 3bcb11e0..2448fcf9 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -12,6 +12,12 @@ const babelProd = { }; +const babelDev = { + exclude: "node_modules/**", + plugins: ["@babel/plugin-transform-react-jsx-development"], + babelHelpers: "bundled", +}; + const warn = { onwarn(warning, warn) { // ignores any 'use client' directive warnings @@ -58,11 +64,7 @@ export default [ nodeResolve(), commonjs(), typescript(), - babel({ - exclude: "node_modules/**", - plugins: ["@babel/plugin-transform-react-jsx-development"], - babelHelpers: "bundled", - }), + babel(babelDev), ], ...warn, }, From 8417338422242ed9fe49a66980551e0118525e5b Mon Sep 17 00:00:00 2001 From: Bradley Momberger Date: Wed, 18 Dec 2024 19:26:42 -0500 Subject: [PATCH 5/6] [TR-79] Code formatting and cleanups --- public/jira/history/components.ts | 36 +- public/jira/history/hooks.ts | 150 ++++---- public/jira/history/observable.ts | 225 +++++------ .../historical-adjusted-estimated-time.js | 2 +- public/react/SaveReports/SaveReports.tsx | 7 +- .../SaveReports/SaveReportsWrapper.test.tsx | 4 +- .../react/SaveReports/SaveReportsWrapper.tsx | 6 +- .../SavedReportDropdown/RecentReports.tsx | 4 +- .../useSelectedReports/useSelectedReport.ts | 14 +- .../hooks/useSelectedReports/utilities.ts | 9 +- public/react/Stats/Stats.tsx | 241 ++++++------ public/react/ViewReports/ViewReport.test.tsx | 2 +- public/react/ViewReports/ViewReports.tsx | 5 +- .../react/ViewReports/ViewReportsWrapper.tsx | 11 +- public/reports/table-grid.js | 352 ++++++++++-------- public/shared/route-pushstate.ts | 43 +-- scripts/atlassian-connect/index.ts | 10 +- vite.config.ts | 4 +- 18 files changed, 570 insertions(+), 555 deletions(-) diff --git a/public/jira/history/components.ts b/public/jira/history/components.ts index 389dbed7..a89d125e 100644 --- a/public/jira/history/components.ts +++ b/public/jira/history/components.ts @@ -1,41 +1,35 @@ -import { MouseEvent, PropsWithChildren, default as React } from 'react'; +import { MouseEvent, PropsWithChildren, default as React } from "react"; -import routeDataObservable from '@routing-observable'; -import { deparam, param } from '../../can'; +import routeDataObservable from "@routing-observable"; +import { deparam, param } from "../../can"; interface RoutingLinkPropsBase extends PropsWithChildren { - as?: 'a' | 'button'; + as?: "a" | "button"; replaceAll?: boolean; className?: string; } -type RoutingLinkProps = RoutingLinkPropsBase & ( - { data: Record; href?: string; } | - { href: string; data?: Record; } -) +type RoutingLinkProps = RoutingLinkPropsBase & + ( + | { data: Record; href?: string } + | { href: string; data?: Record } + ); - export const RoutingLink = ({ - as = 'a', - replaceAll, - children, - className, - ...rest -}: RoutingLinkProps) => { +export const RoutingLink = ({ as = "a", replaceAll, children, className, ...rest }: RoutingLinkProps) => { let href; if (rest.href) { href = new URL(rest.href, document.baseURI).search; - } - else { + } else { href = param(rest.data); } return React.createElement(as, { - ...(as === 'a' ? { href } : {}), + ...(as === "a" ? { href } : {}), onClick: (ev: MouseEvent) => { ev.preventDefault(); const patchSet = rest.data ? { ...rest.data } : (deparam(href) as Record); - if(replaceAll) { - Object.keys(routeDataObservable.get()).forEach(key => { - if(!patchSet[key]) { + if (replaceAll) { + Object.keys(routeDataObservable.get()).forEach((key) => { + if (!patchSet[key]) { patchSet[key] = null; } }); diff --git a/public/jira/history/hooks.ts b/public/jira/history/hooks.ts index d696ced4..8b52d714 100644 --- a/public/jira/history/hooks.ts +++ b/public/jira/history/hooks.ts @@ -1,76 +1,74 @@ -import { useEffect, useState } from 'react'; -import routeDataObservable, { pushStateObservable } from '@routing-observable'; - -export const useHistoryStateValue: - (key: string) => [string | undefined, (val: string | undefined) => void] = - (key) => { - const [value, setValue] = useState(routeDataObservable.get(key)); - useEffect(() => { - const handler = (newVal?: string) => { - setValue(newVal); - }; - - routeDataObservable.on(key, handler); - - return () => routeDataObservable.off(key, handler); - }, []); - - const exportSetValue = (val: string | undefined) => routeDataObservable.set(key, val ?? null); - return [value, exportSetValue]; - }; - -export const useHistoryState: - () => [Record, (val: Record) => void] = - () => { - const [value, setValue] = useState>((routeDataObservable.get() ?? {})); - useEffect(() => { - const handler = (newVal: Record) => { - setValue(newVal ?? {}); - }; - - routeDataObservable.on(undefined, handler); - - return () => routeDataObservable.off(undefined, handler); - }, []); - - const exportSetValue = (val: Record) => routeDataObservable.set(val); - return [value, exportSetValue]; - }; - -export const useHistoryParams: - () => [string, (val: string) => void] = - () => { - const [value, setValue] = useState(pushStateObservable.value); - useEffect(() => { - const handler = () => { - setValue(pushStateObservable.value); - }; - - routeDataObservable.on(handler); - - return () => routeDataObservable.off(handler); - }, []); - - const exportSetValue = (val: string) => pushStateObservable.set(val); - return [value, setValue]; - }; - - -export const useHistoryValueCallback: - (key: string, callback: (newVal: string | undefined) => void) => void = - (key, callback) => { - useEffect(() => { - routeDataObservable.on(key, callback); - return () => routeDataObservable.off(key, callback); - }, [key, callback]); - }; - -export const useHistoryCallback: - (callback: (newVal: Record) => void) => void = - (callback) => { - useEffect(() => { - routeDataObservable.on(undefined, callback); - return () => routeDataObservable.off(undefined, callback); - }, [callback]); - }; - +import { useEffect, useState } from "react"; +import routeDataObservable, { pushStateObservable } from "@routing-observable"; + +export const useHistoryStateValue: ( + key: string, +) => [string | undefined, (val: string | undefined) => void] = (key) => { + const [value, setValue] = useState(routeDataObservable.get(key)); + useEffect(() => { + const handler = (newVal?: string) => { + setValue(newVal); + }; + + routeDataObservable.on(key, handler); + + return () => routeDataObservable.off(key, handler); + }, []); + + const exportSetValue = (val: string | undefined) => routeDataObservable.set(key, val ?? null); + return [value, exportSetValue]; +}; + +export const useHistoryState: () => [ + Record, + (val: Record) => void, +] = () => { + const [value, setValue] = useState>(routeDataObservable.get() ?? {}); + useEffect(() => { + const handler = (newVal: Record) => { + setValue(newVal ?? {}); + }; + + routeDataObservable.on(undefined, handler); + + return () => routeDataObservable.off(undefined, handler); + }, []); + + const exportSetValue = (val: Record) => routeDataObservable.set(val); + return [value, exportSetValue]; +}; + +export const useHistoryParams: () => [string, (val: string) => void] = () => { + const [value, setValue] = useState(pushStateObservable.value); + useEffect(() => { + const handler = () => { + setValue(pushStateObservable.value); + }; + + routeDataObservable.on(handler); + + return () => routeDataObservable.off(handler); + }, []); + + const exportSetValue = (val: string) => pushStateObservable.set(val); + return [value, setValue]; +}; + +export const useHistoryValueCallback: ( + key: string, + callback: (newVal: string | undefined) => void, +) => void = (key, callback) => { + useEffect(() => { + routeDataObservable.on(key, callback); + return () => routeDataObservable.off(key, callback); + }, [key, callback]); +}; + +export const useHistoryCallback: (callback: (newVal: Record) => void) => void = ( + callback, +) => { + useEffect(() => { + routeDataObservable.on(undefined, callback); + return () => routeDataObservable.off(undefined, callback); + }, [callback]); +}; diff --git a/public/jira/history/observable.ts b/public/jira/history/observable.ts index 03f234ba..dde35929 100644 --- a/public/jira/history/observable.ts +++ b/public/jira/history/observable.ts @@ -7,30 +7,32 @@ interface JiraLocationState { query?: { state?: string; }; - state?: Record | null; + state?: Record | null; title: string; href: string; } -type CanPatch = { - type: 'add' | 'set'; - key: string; - value: any; -} | { - type: 'delete'; - key: string; -} +type CanPatch = + | { + type: "add" | "set"; + key: string; + value: any; + } + | { + type: "delete"; + key: string; + }; declare global { interface AP { history: { - getState: ( + getState: ( type?: T, - callback?: (state: JiraLocationState) => void - ) => (T extends 'all' ? JiraLocationState : string); + callback?: (state: JiraLocationState) => void, + ) => T extends "all" ? JiraLocationState : string; replaceState: (state: Partial) => void; subscribeState: (type: string, callback: (state: JiraLocationState) => void) => void; - } + }; } } @@ -52,8 +54,8 @@ export interface JiraStateSync { [offPatchesSymbol]: (handler: KeyHandler, queue?: string) => void; [getKeyValueSymbol]: (key: string) => string | undefined; [setKeyValueSymbol]: (key: string, value: string | null) => void; - [isMapLikeSymbol]: true, - [isValueLikeSymbol]: true, + [isMapLikeSymbol]: true; + [isValueLikeSymbol]: true; on: AddHandlerWithOptionalKey; off: AddHandlerWithOptionalKey; get: ObjectGetter; @@ -69,7 +71,7 @@ interface AddHandlerWithOptionalKey { interface ObjectGetter { (): Record; (key: undefined): Record; - (key: string): (string | undefined); + (key: string): string | undefined; } const handlers = new Map>(); @@ -78,32 +80,24 @@ const valueHandlers = new Set(); let lastQuery: Record | null = null; let disablePushState = false; -const searchParamsToObject = (params: URLSearchParams) => ( - Array.from(params.entries()).reduce((a, [key, val]) => ({ ...a, [key]: val }), {}) -); +const searchParamsToObject = (params: URLSearchParams) => + Array.from(params.entries()).reduce((a, [key, val]) => ({ ...a, [key]: val }), {}); -const keyblocklist = [ - "xdm_e", - "xdm_c", - "cp", - "xdm_deprecated_addon_key_do_not_use", - "lic", - "cv", -]; +const keyblocklist = ["xdm_e", "xdm_c", "cp", "xdm_deprecated_addon_key_do_not_use", "lic", "cv"]; -const stateApi: Pick = { -/** - * on(key, handler): fire `handler` when `key` changes on the history state. - * handler receives the new string or undefined value and the previous string or undefined value - * on(handler) or on(undefined, handler): fire `handler` when any property changes in history state - * handler receives the new state and the previous state - * on('can.patches', handler): fire 'handler' when any property changes in the history state - * handler receives an array of CanPatch objects describing changes to the state. - * - * `queue` is not used and is only preserved for CanJS compatibility - */ - on: function(key, handler, queue) { - if (typeof key === 'function' && typeof handler !== 'function') { +const stateApi: Pick = { + /** + * on(key, handler): fire `handler` when `key` changes on the history state. + * handler receives the new string or undefined value and the previous string or undefined value + * on(handler) or on(undefined, handler): fire `handler` when any property changes in history state + * handler receives the new state and the previous state + * on('can.patches', handler): fire 'handler' when any property changes in the history state + * handler receives an array of CanPatch objects describing changes to the state. + * + * `queue` is not used and is only preserved for CanJS compatibility + */ + on: function (key, handler, queue) { + if (typeof key === "function" && typeof handler !== "function") { queue = handler; handler = key; key = undefined; @@ -111,12 +105,12 @@ const stateApi: Pick = { if (!key) { valueHandlers.add(handler); - } else if (key === 'can.patches') { + } else if (key === "can.patches") { patchHandlers.set(handler, (patches) => { if (patches.length) { handler(patches, undefined); } - }) + }); } else if (handlers.has(key)) { const keyHandlers = handlers.get(key)!; keyHandlers.add(handler); @@ -125,41 +119,41 @@ const stateApi: Pick = { handlerSet.add(handler); handlers.set(key, handlerSet); } - } as JiraStateSync['on'], -/** - * off(key, handler): remove handler from dispatch targets for key - * off(handler) or off(undefined, handler): remove handler from dispatch targets for full history state - * of('can.patches', handler): remove handler from dispatch targets for 'can.patches' - * - * `queue` is not used and is only preserved for CanJS compatibility - */ - off: function(key, handler, queue) { - if (typeof key === 'function' && typeof handler !== 'function') { + } as JiraStateSync["on"], + /** + * off(key, handler): remove handler from dispatch targets for key + * off(handler) or off(undefined, handler): remove handler from dispatch targets for full history state + * of('can.patches', handler): remove handler from dispatch targets for 'can.patches' + * + * `queue` is not used and is only preserved for CanJS compatibility + */ + off: function (key, handler, queue) { + if (typeof key === "function" && typeof handler !== "function") { queue = handler; handler = key; key = undefined; } if (!key) { valueHandlers.delete(handler); - } else if (key === 'can.patches') { + } else if (key === "can.patches") { patchHandlers.delete(handler); } else if (handlers.has(key)) { const keyHandlers = handlers.get(key)!; keyHandlers.delete(handler); } - } as JiraStateSync['off'], + } as JiraStateSync["off"], /** * get(key): return the possibly undefined string value of `key` on the state * get() or get(undefined): return the current history state as a Record of string values */ - get: function(key?: string) { - const params = new URLSearchParams(decodeURIComponent(AP?.history.getState('all').query?.state ?? '')); + get: function (key?: string) { + const params = new URLSearchParams(decodeURIComponent(AP?.history.getState("all").query?.state ?? "")); if (arguments.length > 0) { return params.get(key!); } else { return searchParamsToObject(params); } - } as JiraStateSync['get'], + } as JiraStateSync["get"], /** * set(key, value): set the property `key` to `value` on the state * set(value: Object): set the history state to `value`. Do not preserve any previous property values @@ -170,13 +164,13 @@ const stateApi: Pick = { */ set(...args) { const [keyOrState, val] = args; - const { query } = AP?.history.getState('all') ?? {}; - const { state: currentState = '' } = query ?? {}; + const { query } = AP?.history.getState("all") ?? {}; + const { state: currentState = "" } = query ?? {}; const currentParams = deparam(decodeURIComponent(currentState)); let newParams: Record = { ...currentParams }; let newState: string; - if (typeof keyOrState === 'string') { + if (typeof keyOrState === "string") { if (args.length === 1) { // urlData form, assume that the set() is for the full URL string. newParams = deparam(keyOrState) as Record; @@ -187,21 +181,21 @@ const stateApi: Pick = { } } else { Object.entries(keyOrState).forEach(([key, val]) => { - if(val == null) { + if (val == null) { delete newParams[key]; } else { newParams[key] = val; } }); } - keyblocklist.forEach(key => { + keyblocklist.forEach((key) => { delete newParams[key]; }); if ( Object.keys(newParams) - .concat(Object.keys(currentParams)) - .reduce((a, b) => a && (newParams[b] === currentParams?.[b]), true) + .concat(Object.keys(currentParams)) + .reduce((a, b) => a && newParams[b] === currentParams?.[b], true) ) { // no keys have changed. // Do not dispatch a new state to the AP history @@ -212,66 +206,74 @@ const stateApi: Pick = { AP?.history.replaceState({ query: { state: encodeURIComponent(newURLParams.toString()) }, - state: { fromPopState: 'false' } - }) + state: { fromPopState: "false" }, + }); }, -} +}; -const dispatchKeyHandlers: (key: string, newValue?: string | null, oldValue?: string | null) => void = (key, newValue, oldValue) => { +const dispatchKeyHandlers: (key: string, newValue?: string | null, oldValue?: string | null) => void = ( + key, + newValue, + oldValue, +) => { const keyHandlers = handlers.get(key); - if(keyHandlers) { - keyHandlers.forEach(handler => { + if (keyHandlers) { + keyHandlers.forEach((handler) => { handler(newValue, oldValue); - }) + }); } -} +}; /** - * construct and dispatch "patches", which detail the changes made from the + * construct and dispatch "patches", which detail the changes made from the * previous state to the current one. */ -const dispatchPatchHandlers = (queryParams: Record, lastQuery: Record | null) => { +const dispatchPatchHandlers = ( + queryParams: Record, + lastQuery: Record | null, +) => { if (patchHandlers.size > 0) { const patches = []; if (lastQuery == null) { const newEntries = Object.entries(queryParams); - patches.push(...newEntries.map(([key, val]) => ({ key, type: 'add', value: val }))); + patches.push(...newEntries.map(([key, val]) => ({ key, type: "add", value: val }))); } else if (Object.keys(queryParams).length < 1) { const oldEntries = Object.entries(lastQuery); - patches.push(...oldEntries.map(([key]) => ({ key, type: 'delete' }))); + patches.push(...oldEntries.map(([key]) => ({ key, type: "delete" }))); } else { const newKeys = Object.keys(queryParams); const oldKeys = Object.keys(lastQuery); - const adds = newKeys.filter(key => !oldKeys.includes(key)); - const dels = oldKeys.filter(key => !newKeys.includes(key)); - const sets = newKeys.filter(key => !adds.includes(key)); + const adds = newKeys.filter((key) => !oldKeys.includes(key)); + const dels = oldKeys.filter((key) => !newKeys.includes(key)); + const sets = newKeys.filter((key) => !adds.includes(key)); patches.push( - ...dels.map(key => ({ type: 'delete', key })), - ...sets.filter(key => queryParams[key] !== lastQuery?.[key]).map(key => ({ type: 'set', key, value: queryParams[key] })), - ...adds.map(key => ({ type: 'add', key, value: queryParams[key] })), + ...dels.map((key) => ({ type: "delete", key })), + ...sets + .filter((key) => queryParams[key] !== lastQuery?.[key]) + .map((key) => ({ type: "set", key, value: queryParams[key] })), + ...adds.map((key) => ({ type: "add", key, value: queryParams[key] })), ); } - for(const handler of patchHandlers.values()) { + for (const handler of patchHandlers.values()) { handler(patches, undefined); - }; - - } -} + } + } +}; export const browserState: JiraStateSync = { ...stateApi, // CanJS reflection symbols let this observable be used with `listenTo` in value resolvers. [onKeyValueSymbol]: stateApi.on, [offKeyValueSymbol]: stateApi.off, - [onPatchesSymbol]: stateApi.on.bind(null, 'can.patches'), - [offPatchesSymbol]: stateApi.off.bind(null, 'can.patches'), + [onPatchesSymbol]: stateApi.on.bind(null, "can.patches"), + [offPatchesSymbol]: stateApi.off.bind(null, "can.patches"), [getKeyValueSymbol]: stateApi.get, [setKeyValueSymbol]: stateApi.set as JiraStateSync[typeof setKeyValueSymbol], [isValueLikeSymbol]: true, [isMapLikeSymbol]: true, // Special `value` getter/setter which lets the browser state object behave similar to - // a PushStateObservable. `value` resolves to the parameterized string form of the + // a PushStateObservable. `value` resolves to the parameterized string form of the // history state. get value() { const params = this.get(); @@ -301,11 +303,11 @@ AP?.history?.subscribeState("change", ({ query, state }: JiraLocationState) => { if (!lastQuery && !query) { return; } - const decodedQuery = decodeURIComponent(query?.state ?? ''); + const decodedQuery = decodeURIComponent(query?.state ?? ""); const queryParams = deparam(decodedQuery); // if we popped the state to get the new state, don't push yet another new state on the history - disablePushState = state?.fromPopState === 'true'; + disablePushState = state?.fromPopState === "true"; if (query?.state) { Object.entries(queryParams).forEach(([key, val]) => { @@ -318,19 +320,19 @@ AP?.history?.subscribeState("change", ({ query, state }: JiraLocationState) => { if (lastVal !== undefined) { dispatchKeyHandlers(key, undefined, lastVal); } - }); + }); } dispatchPatchHandlers(queryParams, lastQuery); disablePushState = false; // Value handlers are dispatched after patch handlers - // because the local URL is being updated by a patch + // because the local URL is being updated by a patch // handler, while the observables looking at the URL // are using listenTo() which relies on value handlers const lastQueryParams = new URLSearchParams(lastQuery ?? {}); lastQueryParams.sort(); const lastQueryString = lastQueryParams.toString(); - valueHandlers.forEach(handler => { + valueHandlers.forEach((handler) => { handler(decodedQuery, lastQueryParams); }); @@ -339,24 +341,24 @@ AP?.history?.subscribeState("change", ({ query, state }: JiraLocationState) => { export default browserState; // This listener keeps the frame URL search in sync with // the outer page's `ac` params, allowing our existing -// observables to continue using the interior URL for +// observables to continue using the interior URL for // values. -browserState.on('can.patches', (patches: CanPatch[]) => { +browserState.on("can.patches", (patches: CanPatch[]) => { const currentParams = new URLSearchParams(location.search); patches.forEach((patch) => { - if (patch.type === 'delete') { + if (patch.type === "delete") { currentParams.delete(patch.key); } else { currentParams.set(patch.key, patch.value); } - }) + }); currentParams.sort(); const paramString = currentParams.toString(); - keyblocklist.forEach(key => currentParams.delete(key)); + keyblocklist.forEach((key) => currentParams.delete(key)); if (!disablePushState) { - history.pushState(searchParamsToObject(currentParams), '', `?${paramString}`); + history.pushState(searchParamsToObject(currentParams), "", `?${paramString}`); } }); @@ -365,13 +367,16 @@ browserState.on('can.patches', (patches: CanPatch[]) => { * We send the change back up to the parent frame, which will then trigger the "change" * event and fire any key or value event listeners bound to the history state. */ -window.addEventListener('popstate', () => { +window.addEventListener("popstate", () => { const { search } = window.location; const searchParams = new URLSearchParams(search); searchParams.sort(); - keyblocklist.forEach(key => searchParams.delete(key)); + keyblocklist.forEach((key) => searchParams.delete(key)); - AP?.history.replaceState({ query: { state: encodeURIComponent(searchParams.toString()) }, state: { fromPopState: 'true' }}); + AP?.history.replaceState({ + query: { state: encodeURIComponent(searchParams.toString()) }, + state: { fromPopState: "true" }, + }); }); /** @@ -379,8 +384,8 @@ window.addEventListener('popstate', () => { * If any params have been set on the URL directly, i.e. from the Atlassian Connect app URL, * add those to the initial state as well. */ -AP?.history?.getState('all', ({ query }) => { - const newState = decodeURIComponent(query?.state ?? '') +AP?.history?.getState("all", ({ query }) => { + const newState = decodeURIComponent(query?.state ?? ""); const { search } = window.location; const searchParams = new URLSearchParams(search); const newStateParams = new URLSearchParams(newState); @@ -388,16 +393,16 @@ AP?.history?.getState('all', ({ query }) => { searchParams.set(key, val); }); searchParams.sort(); - keyblocklist.forEach(key => { + keyblocklist.forEach((key) => { searchParams.delete(key); - }) + }); const combinedState = searchParamsToObject(searchParams); searchParams.forEach(([key, val]) => { dispatchKeyHandlers(key, val, undefined); }); - dispatchPatchHandlers(combinedState, null) - valueHandlers.forEach(handler => { + dispatchPatchHandlers(combinedState, null); + valueHandlers.forEach((handler) => { handler(newState, undefined); }); lastQuery = combinedState; diff --git a/public/jira/rollup/historical-adjusted-estimated-time/historical-adjusted-estimated-time.js b/public/jira/rollup/historical-adjusted-estimated-time/historical-adjusted-estimated-time.js index 216c8297..9d3337d0 100644 --- a/public/jira/rollup/historical-adjusted-estimated-time/historical-adjusted-estimated-time.js +++ b/public/jira/rollup/historical-adjusted-estimated-time/historical-adjusted-estimated-time.js @@ -274,7 +274,7 @@ function logNormalAverageConfidenceInterval(values) { } function getTeamAverageEstimatedPointPerDay(epics) { - const epicsToUseAsBaseline = epics.filter(issueWasEstimatedDatedAndCompleted); + const epicsToUseAsBaseline = epics?.filter(issueWasEstimatedDatedAndCompleted) ?? []; const metadata = { completedEstimatedVSActualForTeam: {} }; epicsToUseAsBaseline.forEach((epic) => { addEstimatedAndActualForTeam(metadata, epic); diff --git a/public/react/SaveReports/SaveReports.tsx b/public/react/SaveReports/SaveReports.tsx index cd29f72c..5608d71c 100644 --- a/public/react/SaveReports/SaveReports.tsx +++ b/public/react/SaveReports/SaveReports.tsx @@ -17,7 +17,7 @@ import routeDataObservable, { pushStateObservable as queryParamObservable } from import { useHistoryState, useHistoryStateValue, useHistoryValueCallback } from "../../jira/history/hooks"; import { param } from "../../can"; -interface SaveReportProps { +export interface SaveReportProps { onViewReportsButtonClicked: () => void; } @@ -47,9 +47,8 @@ const SaveReport: FC = ({ onViewReportsButtonClicked }) => { const { recentReports, addReportToRecents } = useRecentReports(); - const [ queryParams ] = useHistoryState(); + const [queryParams] = useHistoryState(); useHistoryValueCallback("report", (report: string | undefined) => { - // TODO: If confirm `report` exists in `reports` before adding // TODO: Reconcile deleted reports with whats there @@ -85,7 +84,7 @@ const SaveReport: FC = ({ onViewReportsButtonClicked }) => { url.searchParams.set("report", id); queryParamObservable.set(url.search); }, - } + }, ); }; diff --git a/public/react/SaveReports/SaveReportsWrapper.test.tsx b/public/react/SaveReports/SaveReportsWrapper.test.tsx index 583020dd..817e6007 100644 --- a/public/react/SaveReports/SaveReportsWrapper.test.tsx +++ b/public/react/SaveReports/SaveReportsWrapper.test.tsx @@ -17,7 +17,7 @@ describe("", () => { update: vi.fn(), }} onViewReportsButtonClicked={mockOnViewReportsButtonClicked} - /> + />, ); await waitFor(() => { @@ -37,7 +37,7 @@ describe("", () => { update: vi.fn(), }} onViewReportsButtonClicked={mockOnViewReportsButtonClicked} - /> + />, ); await waitFor(() => { diff --git a/public/react/SaveReports/SaveReportsWrapper.tsx b/public/react/SaveReports/SaveReportsWrapper.tsx index 50872a92..077b7e8b 100644 --- a/public/react/SaveReports/SaveReportsWrapper.tsx +++ b/public/react/SaveReports/SaveReportsWrapper.tsx @@ -10,12 +10,10 @@ import { FlagsProvider } from "@atlaskit/flag"; import { StorageProvider } from "../services/storage"; import Skeleton from "../components/Skeleton"; -import SaveReports from "./SaveReports"; +import SaveReports, { SaveReportProps } from "./SaveReports"; -interface SaveReportsWrapperProps { +interface SaveReportsWrapperProps extends SaveReportProps { storage: AppStorage; - onViewReportsButtonClicked: () => void; - queryParamObservable: CanObservable; } const queryClient = new QueryClient(); diff --git a/public/react/SaveReports/components/SavedReportDropdown/RecentReports.tsx b/public/react/SaveReports/components/SavedReportDropdown/RecentReports.tsx index 63a8a3b6..a25d5528 100644 --- a/public/react/SaveReports/components/SavedReportDropdown/RecentReports.tsx +++ b/public/react/SaveReports/components/SavedReportDropdown/RecentReports.tsx @@ -68,8 +68,8 @@ const ReportListItem: FC = ({ reports, reportId }) => { href={"?" + matched.queryParams} onClick={(ev) => { ev.preventDefault(); - const { href } = (ev.target as HTMLElement).closest('a') as HTMLAnchorElement; - pushStateObservable.set(new URL(href).search) + const { href } = (ev.target as HTMLElement).closest("a") as HTMLAnchorElement; + pushStateObservable.set(new URL(href).search); }} > {matched.name} diff --git a/public/react/SaveReports/hooks/useSelectedReports/useSelectedReport.ts b/public/react/SaveReports/hooks/useSelectedReports/useSelectedReport.ts index 9b9cbf88..b57b7c11 100644 --- a/public/react/SaveReports/hooks/useSelectedReports/useSelectedReport.ts +++ b/public/react/SaveReports/hooks/useSelectedReports/useSelectedReport.ts @@ -8,23 +8,17 @@ import { useHistoryCallback, useHistoryParams, useHistoryState } from "../../../ import routeDataObservable from "@routing-observable"; import { param } from "../../../../can"; -export const useSelectedReport = ({ - reports, -}: { - reports: Reports; -}) => { +export const useSelectedReport = ({ reports }: { reports: Reports }) => { const { updateReport } = useUpdateReport(); const [selectedReport, setSelectedReport] = useState(() => - getReportFromParams(reports) + getReportFromParams(reports), ); const [initial] = useHistoryState(); const [search] = useHistoryParams(); - const [isDirty, setIsDirty] = useState( - () => !paramsMatchReport(new URLSearchParams(search), reports) - ); + const [isDirty, setIsDirty] = useState(() => !paramsMatchReport(new URLSearchParams(search), reports)); useHistoryCallback((params) => { const newSelectedReport = getReportFromParams(reports); @@ -50,7 +44,7 @@ export const useSelectedReport = ({ updateReport( selectedReport.id, { queryParams: param(queryParams) }, - { onSuccess: () => setIsDirty(false) } + { onSuccess: () => setIsDirty(false) }, ); }, isDirty, diff --git a/public/react/SaveReports/hooks/useSelectedReports/utilities.ts b/public/react/SaveReports/hooks/useSelectedReports/utilities.ts index 943d066d..fc1fb496 100644 --- a/public/react/SaveReports/hooks/useSelectedReports/utilities.ts +++ b/public/react/SaveReports/hooks/useSelectedReports/utilities.ts @@ -1,5 +1,5 @@ import type { Reports, Report } from "../../../../jira/reports"; -import routeDataObservable from '@routing-observable'; +import routeDataObservable from "@routing-observable"; export const paramsEqual = (lhs: URLSearchParams, rhs: URLSearchParams): boolean => { const lhsEntries = [...lhs.entries()]; @@ -10,10 +10,7 @@ export const paramsEqual = (lhs: URLSearchParams, rhs: URLSearchParams): boolean } return lhsEntries.reduce((isEqual, [lhsName, lhsValue]) => { - return ( - isEqual && - rhsEntries.some(([rhsName, rhsValue]) => lhsName === rhsName && lhsValue === rhsValue) - ); + return isEqual && rhsEntries.some(([rhsName, rhsValue]) => lhsName === rhsName && lhsValue === rhsValue); }, true); }; @@ -32,7 +29,7 @@ export const getReportFromParams = (reports: Reports): Report | undefined => { export const paramsMatchReport = ( params: URLSearchParams, reports: Reports, - paramsToOmit: string[] = ["settings"] + paramsToOmit: string[] = ["settings"], ) => { const report = getReportFromParams(reports); diff --git a/public/react/Stats/Stats.tsx b/public/react/Stats/Stats.tsx index bcae287e..7b160111 100644 --- a/public/react/Stats/Stats.tsx +++ b/public/react/Stats/Stats.tsx @@ -4,161 +4,160 @@ import React, { useState, useEffect } from "react"; import type { DerivedIssue } from "../../jira/derived/derive"; import { useHistoryStateValue } from "../../jira/history/hooks"; -import { jStat } from 'jstat'; +import { jStat } from "jstat"; type CanObservable = { - value: Value; - on: (handler: any)=> {}, - off: (handler: any) => {} -} + value: Value; + on: (handler: any) => {}; + off: (handler: any) => {}; +}; type RolledUpIssue = DerivedIssue & { - completionRollup: {totalWorkingDays: number}, - historicalAdjustedEstimatedTime: Array<{historicalAdjustedEstimatedTime: number, teamName: String}> -} + completionRollup: { totalWorkingDays: number }; + historicalAdjustedEstimatedTime: Array<{ historicalAdjustedEstimatedTime: number; teamName: String }>; +}; type GetInnerType = T extends CanObservable ? U : never; function round(number: any, decimals: number = 0) { - return typeof number === "number" && !isNaN(number) ? parseFloat(number.toFixed(decimals)) : "∅" + return typeof number === "number" && !isNaN(number) ? parseFloat(number.toFixed(decimals)) : "∅"; } function getLogNormalData(values: Array) { - const logData = values.map(Math.log); - const logMean : number = jStat.mean(logData); - const stdDev: number = jStat.stdev(logData, true); - - const sortedData = values.slice().sort((a, b) => a - b); - const n = sortedData.length; - const median = n % 2 === 0 - ? (sortedData[n / 2 - 1] + sortedData[n / 2]) / 2 - : sortedData[Math.floor(n / 2)]; - return {logMean, stdDev, median, mean: jStat.mean(values), sum: values.reduce(sumNumbers, 0)}; + const logData = values.map(Math.log); + const logMean: number = jStat.mean(logData); + const stdDev: number = jStat.stdev(logData, true); + + const sortedData = values.slice().sort((a, b) => a - b); + const n = sortedData.length; + const median = + n % 2 === 0 ? (sortedData[n / 2 - 1] + sortedData[n / 2]) / 2 : sortedData[Math.floor(n / 2)]; + return { logMean, stdDev, median, mean: jStat.mean(values), sum: values.reduce(sumNumbers, 0) }; } function sumNumbers(acc: number, cur: number) { - return acc+cur; + return acc + cur; } function getNormalData(values: Array) { - const mean : number = jStat.mean(values); - const stdDev: number = jStat.stdev(values, true); - const sortedData = values.slice().sort((a, b) => a - b); - const n = sortedData.length; - const median = n % 2 === 0 - ? (sortedData[n / 2 - 1] + sortedData[n / 2]) / 2 - : sortedData[Math.floor(n / 2)]; - return {mean, stdDev, median, sum: values.reduce(sumNumbers, 0)}; + const mean: number = jStat.mean(values); + const stdDev: number = jStat.stdev(values, true); + const sortedData = values.slice().sort((a, b) => a - b); + const n = sortedData.length; + const median = + n % 2 === 0 ? (sortedData[n / 2 - 1] + sortedData[n / 2]) / 2 : sortedData[Math.floor(n / 2)]; + return { mean, stdDev, median, sum: values.reduce(sumNumbers, 0) }; } -const NormalDataOutput : FC> = ({mean, stdDev, median, sum}) => { - return ( - <> -
- - - - - ) -} +const NormalDataOutput: FC> = ({ mean, stdDev, median, sum }) => { + return ( + <> + + + + + + ); +}; function getTeamNames(issues: Array) { - const teamNames = new Set(); - for(let issue of issues) { - for(let teamRecord of issue.historicalAdjustedEstimatedTime) { - teamNames.add(teamRecord.teamName); - } + const teamNames = new Set(); + for (let issue of issues) { + for (let teamRecord of issue.historicalAdjustedEstimatedTime) { + teamNames.add(teamRecord.teamName); } - return [...teamNames] as Array; + } + return [...teamNames] as Array; } +const ConfigurationPanel: FC<{ primaryIssuesOrReleasesObs: CanObservable> }> = ({ + primaryIssuesOrReleasesObs, +}) => { + const issues = useCanObservable(primaryIssuesOrReleasesObs); + const [jql] = useHistoryStateValue("jql"); + if (!issues?.length) { + return
Loading ...
; + } - -const ConfigurationPanel: FC<{primaryIssuesOrReleasesObs: CanObservable>}> = ({ primaryIssuesOrReleasesObs }) => { - const issues = useCanObservable(primaryIssuesOrReleasesObs); - const [jql] = useHistoryStateValue('jql'); - if(!issues?.length) { - return
Loading ...
- } - - const allTeamNames = getTeamNames(issues) - - return ( -
-
{issues.length} items
-
{round( sum ) } {round( mean ) } {round(median)} {round(stdDev, 3)}{round(sum)} {round(mean)} {round(median)} {round(stdDev, 3)}
- - - - {allTeamNames.map( teamName => { - return - })} - - - - - {issues.map( (issue) => { - return - - {allTeamNames.map( teamName => { - return - })} - - + const allTeamNames = getTeamNames(issues); + + return ( +
+
{issues.length} items
+
Issue{teamName}
{issue.summary}{ - round( issue.historicalAdjustedEstimatedTime.find( data => data.teamName === teamName )?.historicalAdjustedEstimatedTime ) - }
+ + + + {allTeamNames.map((teamName) => { + return ; })} - -
Issue{teamName}
- - ); + + + + {issues.map((issue) => { + return ( + + {issue.summary} + {allTeamNames.map((teamName) => { + return ( + + {round( + issue.historicalAdjustedEstimatedTime.find((data) => data.teamName === teamName) + ?.historicalAdjustedEstimatedTime, + )} + + ); + })} + + ); + })} + + + + ); }; - function useCanObservable(observable: CanObservable) { - - const [value, setValue] = useState(observable.value); - - useEffect(() => { - const handler = (value: T) => { - setValue(value); - }; + const [value, setValue] = useState(observable.value); - observable.on(handler); + useEffect(() => { + const handler = (value: T) => { + setValue(value); + }; - // Cleanup on unmount. - return () => { - observable.off(handler); - }; - }, [observable]); + observable.on(handler); + // Cleanup on unmount. + return () => { + observable.off(handler); + }; + }, [observable]); - return value; + return value; } -function calculateBusinessDays(ranges: Array<{startDate: Date | null, dueDate: Date | null}>) { - const businessDays = new Set(); // Use a Set to ensure unique business days - - ranges.forEach(({ startDate, dueDate }) => { - if(!startDate || !dueDate) { - return; - } - let current = new Date(startDate); - - while (current <= dueDate) { - const dayOfWeek = current.getDay(); // 0 = Sunday, 6 = Saturday - - // Only add weekdays (Monday to Friday) - if (dayOfWeek >= 1 && dayOfWeek <= 5) { - businessDays.add(current.toDateString()); - } - - // Move to the next day - current.setDate(current.getDate() + 1); - } - }); - - return businessDays.size; // Return the total number of unique business days - } +function calculateBusinessDays(ranges: Array<{ startDate: Date | null; dueDate: Date | null }>) { + const businessDays = new Set(); // Use a Set to ensure unique business days + + ranges.forEach(({ startDate, dueDate }) => { + if (!startDate || !dueDate) { + return; + } + let current = new Date(startDate); + while (current <= dueDate) { + const dayOfWeek = current.getDay(); // 0 = Sunday, 6 = Saturday + + // Only add weekdays (Monday to Friday) + if (dayOfWeek >= 1 && dayOfWeek <= 5) { + businessDays.add(current.toDateString()); + } + + // Move to the next day + current.setDate(current.getDate() + 1); + } + }); + + return businessDays.size; // Return the total number of unique business days +} export default ConfigurationPanel; diff --git a/public/react/ViewReports/ViewReport.test.tsx b/public/react/ViewReports/ViewReport.test.tsx index f2d3ea6c..d852081e 100644 --- a/public/react/ViewReports/ViewReport.test.tsx +++ b/public/react/ViewReports/ViewReport.test.tsx @@ -35,7 +35,7 @@ describe("ViewReports Component", () => { }); it("renders the selected report's name in the reportInfo section", () => { - pushStateObservable.set("?report=1") + pushStateObservable.set("?report=1"); render(); diff --git a/public/react/ViewReports/ViewReports.tsx b/public/react/ViewReports/ViewReports.tsx index a438d344..1ee71406 100644 --- a/public/react/ViewReports/ViewReports.tsx +++ b/public/react/ViewReports/ViewReports.tsx @@ -56,10 +56,7 @@ const ViewReports: FC = ({ onBackButtonClicked }) => { onBackButtonClicked={onBackButtonClicked} reportInfo={selectedReportName ?

{selectedReportName}

: null} > - + ); }; diff --git a/public/react/ViewReports/ViewReportsWrapper.tsx b/public/react/ViewReports/ViewReportsWrapper.tsx index 94b20178..870b7668 100644 --- a/public/react/ViewReports/ViewReportsWrapper.tsx +++ b/public/react/ViewReports/ViewReportsWrapper.tsx @@ -25,7 +25,11 @@ interface ViewReportsWrapperProps { const queryClient = new QueryClient(); -const ViewReportsWrapper: FC = ({ storage, showingReportsObservable, ...viewReportProps }) => { +const ViewReportsWrapper: FC = ({ + storage, + showingReportsObservable, + ...viewReportProps +}) => { const shouldShowReports = useCanObservable(showingReportsObservable); if (!shouldShowReports) { @@ -81,7 +85,10 @@ const ViewReportSkeleton: FC = ({ onBackButtonClicked } }); return ( - : null}> + : null} + > ); diff --git a/public/reports/table-grid.js b/public/reports/table-grid.js index 57317de4..7b8d747a 100644 --- a/public/reports/table-grid.js +++ b/public/reports/table-grid.js @@ -10,11 +10,11 @@ export const dateFormatter = new Intl.DateTimeFormat('en-US', { year: 'numeric' // Full year (e.g., "1982") });*/ -export const dateFormatter = new Intl.DateTimeFormat('en-US', { - month: '2-digit', // Abbreviated month (e.g., "Oct") - day: '2-digit', // Numeric day (e.g., "20") - year: '2-digit' // Full year (e.g., "1982") -}) +export const dateFormatter = new Intl.DateTimeFormat("en-US", { + month: "2-digit", // Abbreviated month (e.g., "Oct") + day: "2-digit", // Numeric day (e.g., "20") + year: "2-digit", // Full year (e.g., "1982") +}); import SimpleTooltip from "../shared/simple-tooltip.js"; @@ -25,9 +25,8 @@ import { createRoot } from "react-dom/client"; import { createElement } from "react"; import Stats from "../react/Stats/Stats.js"; - export class EstimateBreakdown extends StacheElement { - static view = `
+ static view = `
{{# if( this.usedStoryPointsMedian(issue) ) }} @@ -156,55 +155,61 @@ export class EstimateBreakdown extends StacheElement { {{/ if }}
`; - teamsAreTheSame(){ - if(!this.issue.issueLastPeriod) { - return false - } else { - return this.issue.issueLastPeriod.team === this.issue.team - } + teamsAreTheSame() { + if (!this.issue.issueLastPeriod) { + return false; + } else { + return this.issue.issueLastPeriod.team === this.issue.team; + } + } + makeCurrentAndPreviousHTML(valueKey, validKey, format = (x) => x) { + const currentValue = key.get(this.issue, valueKey); + const lastValue = this.issue.issueLastPeriod ? key.get(this.issue.issueLastPeriod, valueKey) : undefined; + + let isCurrentValueValid = true, + lastValueValid = true; + if (validKey) { + isCurrentValueValid = key.get(this.issue, validKey); + lastValueValid = this.issue.issueLastPeriod ? key.get(this.issue.issueLastPeriod, validKey) : undefined; } - makeCurrentAndPreviousHTML(valueKey, validKey, format = (x)=> x){ - const currentValue = key.get(this.issue,valueKey); - const lastValue = this.issue.issueLastPeriod ? key.get(this.issue.issueLastPeriod, valueKey) : undefined; - - let isCurrentValueValid = true, lastValueValid = true; - if(validKey) { - isCurrentValueValid = key.get(this.issue,validKey); - lastValueValid = this.issue.issueLastPeriod ? key.get(this.issue.issueLastPeriod, validKey) : undefined ; - } - return stache.safeString(` -
${ format( this.round(currentValue, 1) ) }
-
- ${ this.issue.issueLastPeriod ? format( this.round(lastValue, 1) ) : "🚫"} + return stache.safeString(` +
${format(this.round(currentValue, 1))}
+
+ ${this.issue.issueLastPeriod ? format(this.round(lastValue, 1)) : "🚫"}
- `) - - } - formatPercent(value) { - return value+"%" - } - usedStoryPointsMedian(issue){ - return issue?.derivedTiming?.isStoryPointsMedianValid && (issue?.derivedTiming?.usedConfidence !== 100) - } - confidenceValue(issue){ - return issue?.derivedTiming?.usedConfidence; - } - confidenceClass(issue){ - return issue?.derivedTiming?.isConfidenceValid ? "" : "bg-neutral-100" - } - timingEquation(issue) { - if(issue?.derivedTiming?.isStoryPointsMedianValid) { - return Math.round( issue.derivedTiming.deterministicTotalPoints ) + - " / " +issue.team.velocity + " / "+issue.team.parallelWorkLimit + " * " + issue.team.daysPerSprint + " = " + - Math.round(issue.derivedTiming.deterministicTotalDaysOfWork) - } - } - round(number, decimals = 0) { - return typeof number === "number" ? parseFloat(number.toFixed(decimals)) : "∅" + `); + } + formatPercent(value) { + return value + "%"; + } + usedStoryPointsMedian(issue) { + return issue?.derivedTiming?.isStoryPointsMedianValid && issue?.derivedTiming?.usedConfidence !== 100; + } + confidenceValue(issue) { + return issue?.derivedTiming?.usedConfidence; + } + confidenceClass(issue) { + return issue?.derivedTiming?.isConfidenceValid ? "" : "bg-neutral-100"; + } + timingEquation(issue) { + if (issue?.derivedTiming?.isStoryPointsMedianValid) { + return ( + Math.round(issue.derivedTiming.deterministicTotalPoints) + + " / " + + issue.team.velocity + + " / " + + issue.team.parallelWorkLimit + + " * " + + issue.team.daysPerSprint + + " = " + + Math.round(issue.derivedTiming.deterministicTotalDaysOfWork) + ); } - - + } + round(number, decimals = 0) { + return typeof number === "number" ? parseFloat(number.toFixed(decimals)) : "∅"; + } } customElements.define("estimate-breakdown", EstimateBreakdown); @@ -248,130 +253,151 @@ export class TableGrid extends StacheElement { `; static props = { columns: { - get default(){ - return [{ - path: "summary", - name: "Summary", - },{ - path: "rollupDates.start", - name: "Rollup Start" - }, - { - path: "rollupDates.start", - name: "Rollup Due" - }] - } - } + get default() { + return [ + { + path: "summary", + name: "Summary", + }, + { + path: "rollupDates.start", + name: "Rollup Start", + }, + { + path: "rollupDates.start", + name: "Rollup Due", + }, + ]; + }, + }, }; - get tableRows(){ - - + get tableRows() { const getChildren = makeGetChildrenFromReportingIssues(this.allIssuesOrReleases); - + function childrenRecursive(issue, depth = 0) { - return [ - {depth: depth, issue}, - ...getChildren(issue).map( issue => childrenRecursive(issue, depth+1)) - ] + return [ + { depth: depth, issue }, + ...getChildren(issue).map((issue) => childrenRecursive(issue, depth + 1)), + ]; } - let allChildren = this.primaryIssuesOrReleases.map( i => childrenRecursive(i)).flat(Infinity) + let allChildren = this.primaryIssuesOrReleases.map((i) => childrenRecursive(i)).flat(Infinity); return allChildren; } - padding(row){ - return "padding-left: "+(row.depth * 20)+"px" + padding(row) { + return "padding-left: " + row.depth * 20 + "px"; } - iconUrl(row){ - return row.issue?.issue?.fields["Issue Type"]?.iconUrl + iconUrl(row) { + return row.issue?.issue?.fields["Issue Type"]?.iconUrl; } shortDate(date) { return date ? dateFormatter.format(date) : ""; } startDate(issue) { - return compareToLast(issue, issue => issue?.rollupDates?.start, formatDate) + return compareToLast(issue, (issue) => issue?.rollupDates?.start, formatDate); } endDate(issue) { - return compareToLast(issue, issue => issue?.rollupDates?.due, formatDate) + return compareToLast(issue, (issue) => issue?.rollupDates?.due, formatDate); } estimate(issue) { - return compareToLast(issue, getEstimate, x => x) - + return compareToLast(issue, getEstimate, (x) => x); } - estimatedDaysOfWork(issue){ - return compareToLast(issue, (issue) => { + estimatedDaysOfWork(issue) { + return compareToLast( + issue, + (issue) => { // if we have story points median, use that - if(issue?.derivedTiming?.isStoryPointsMedianValid) { - return issue.derivedTiming.deterministicTotalDaysOfWork - } else if(issue?.derivedTiming?.isStoryPointsValid ) { - return issue?.derivedTiming?.storyPointsDaysOfWork + if (issue?.derivedTiming?.isStoryPointsMedianValid) { + return issue.derivedTiming.deterministicTotalDaysOfWork; + } else if (issue?.derivedTiming?.isStoryPointsValid) { + return issue?.derivedTiming?.storyPointsDaysOfWork; } - }, value => { - if(typeof value === "number") { - return Math.round(value) + }, + (value) => { + if (typeof value === "number") { + return Math.round(value); } else { - return value; + return value; } - }) + }, + ); } - timedDays(issue){ - return compareToLast(issue, (issue) => { + timedDays(issue) { + return compareToLast( + issue, + (issue) => { // if we have story points median, use that - if(issue?.derivedTiming?.datesDaysOfWork) { - return issue?.derivedTiming?.datesDaysOfWork - } - }, value => { - if(typeof value === "number") { - return Math.round(value) + if (issue?.derivedTiming?.datesDaysOfWork) { + return issue?.derivedTiming?.datesDaysOfWork; + } + }, + (value) => { + if (typeof value === "number") { + return Math.round(value); } else { - return value; + return value; } - }) + }, + ); } - rolledUpDays(issue){ - return compareToLast(issue, (issue) => { + rolledUpDays(issue) { + return compareToLast( + issue, + (issue) => { // if we have story points median, use that - if(issue?.completionRollup?.totalWorkingDays) { - return issue?.completionRollup?.totalWorkingDays; - } - }, value => { - if(typeof value === "number") { - return Math.round(value) + if (issue?.completionRollup?.totalWorkingDays) { + return issue?.completionRollup?.totalWorkingDays; + } + }, + (value) => { + if (typeof value === "number") { + return Math.round(value); } else { - return value; + return value; } - }) + }, + ); } timingEquation(issue) { - if(issue?.derivedTiming?.isStoryPointsMedianValid) { - return Math.round( issue.derivedTiming.deterministicTotalPoints ) + - " / " +issue.team.velocity + " / "+issue.team.parallelWorkLimit + " * " + issue.team.daysPerSprint + " = " + - Math.round(issue.derivedTiming.deterministicTotalDaysOfWork) + if (issue?.derivedTiming?.isStoryPointsMedianValid) { + return ( + Math.round(issue.derivedTiming.deterministicTotalPoints) + + " / " + + issue.team.velocity + + " / " + + issue.team.parallelWorkLimit + + " * " + + issue.team.daysPerSprint + + " = " + + Math.round(issue.derivedTiming.deterministicTotalDaysOfWork) + ); } } showEstimation(issue, element) { - - TOOLTIP.belowElementInScrollingContainer(element, new EstimateBreakdown().initialize({ - issue - })); - - TOOLTIP.querySelector(".remove-button").onclick = ()=> { - TOOLTIP.leftElement() - } - + TOOLTIP.belowElementInScrollingContainer( + element, + new EstimateBreakdown().initialize({ + issue, + }), + ); + + TOOLTIP.querySelector(".remove-button").onclick = () => { + TOOLTIP.leftElement(); + }; } connected() { - if(FEATURE_HISTORICALLY_ADJUSTED_ESTIMATES()) { - let root = this.statsRoot; - if (!root) { - root = this.statsRoot = createRoot(document.getElementById("stats")); - } - root.render( - createElement(Stats, { - primaryIssuesOrReleasesObs: value.from(this,"primaryIssuesOrReleases") - }) - ); + if (FEATURE_HISTORICALLY_ADJUSTED_ESTIMATES()) { + let root = this.statsRoot; + if (!root) { + root = this.statsRoot = createRoot(document.getElementById("stats")); + } + root.render( + createElement(Stats, { + primaryIssuesOrReleasesObs: value.from(this, "primaryIssuesOrReleases"), + }), + ); } } disconnected() { @@ -384,43 +410,39 @@ export class TableGrid extends StacheElement { customElements.define("table-grid", TableGrid); -function getEstimate(issue){ - if(issue?.derivedTiming?.isStoryPointsMedianValid) { - return issue.storyPointsMedian + " "+ issue.confidence+"%" - } else if(issue?.storyPoints != null){ - return issue.storyPoints - } else { - return null; - } +function getEstimate(issue) { + if (issue?.derivedTiming?.isStoryPointsMedianValid) { + return issue.storyPointsMedian + " " + issue.confidence + "%"; + } else if (issue?.storyPoints != null) { + return issue.storyPoints; + } else { + return null; + } } -function anythingToString(value){ - return value == null ? "∅" : ""+value; +function anythingToString(value) { + return value == null ? "∅" : "" + value; } function compareToLast(issue, getValue, formatValue) { - - const currentValue = anythingToString( formatValue( getValue(issue) ) ); - - if(!issue.issueLastPeriod) { - return "🚫 ➡ "+currentValue - } - const lastValue = anythingToString( formatValue( getValue(issue.issueLastPeriod) ) ); + const currentValue = anythingToString(formatValue(getValue(issue))); - if(currentValue !== lastValue) { - return lastValue + " ➡ " + currentValue; - } else { - return currentValue === "∅" ? "" : currentValue; - } + if (!issue.issueLastPeriod) { + return "🚫 ➡ " + currentValue; + } + const lastValue = anythingToString(formatValue(getValue(issue.issueLastPeriod))); + if (currentValue !== lastValue) { + return lastValue + " ➡ " + currentValue; + } else { + return currentValue === "∅" ? "" : currentValue; + } } -function formatDate(date){ - return date ? dateFormatter.format(date) : date; +function formatDate(date) { + return date ? dateFormatter.format(date) : date; } -function getStartDate(issue){ - return formatDate(issue?.rollupDates?.start) +function getStartDate(issue) { + return formatDate(issue?.rollupDates?.start); } - - diff --git a/public/shared/route-pushstate.ts b/public/shared/route-pushstate.ts index db8f3d79..df166752 100644 --- a/public/shared/route-pushstate.ts +++ b/public/shared/route-pushstate.ts @@ -7,7 +7,7 @@ export const pushStateObservable = new RoutePushstate(); route.urlData = pushStateObservable; route.urlData.root = window.location.pathname; // @ts-expect-error -route.register('/'); +route.register("/"); // @ts-expect-error route.start(); @@ -29,26 +29,27 @@ const keyObservable = new (ObservableObject as ObjectConstructor)(); // keyObservable.set(deparam(pushStateObservable.value)); // }); -const proxiedRouteData = new Proxy( - route.data, - { - get(target, p) { - if (p === 'set') { - return function(...args: [string, any] | [any]) { - const value = args.pop(); - const key = args[0]; - - if (!key && value && typeof value === 'object') { - const newValue = Object.entries(value).reduce((a, [key, val]) => ({ ...a, [key]: val ?? undefined }), {}); - target.set.call(newValue); - } else if (key) { - target.set.call(target, key, value ?? undefined); - } - }; - } else { - return target[p]; - } +const proxiedRouteData = new Proxy(route.data, { + get(target, p) { + if (p === "set") { + return function (...args: [string, any] | [any]) { + const value = args.pop(); + const key = args[0]; + + if (!key && value && typeof value === "object") { + const newValue = Object.entries(value).reduce( + (a, [key, val]) => ({ ...a, [key]: val ?? undefined }), + {}, + ); + target.set.call(newValue); + } else if (key) { + target.set.call(target, key, value ?? undefined); + } + }; + } else { + return target[p]; + } }, }); -export default proxiedRouteData; \ No newline at end of file +export default proxiedRouteData; diff --git a/scripts/atlassian-connect/index.ts b/scripts/atlassian-connect/index.ts index 23192dcb..6a5497cf 100644 --- a/scripts/atlassian-connect/index.ts +++ b/scripts/atlassian-connect/index.ts @@ -2,7 +2,11 @@ import fs from "node:fs"; import path from "node:path"; const connectMetadata = { - local: { name: "Timeline Report (Local, Brad)", baseUrl: "https://0342-68-187-209-164.ngrok-free.app", key: "bitovi.timeline-report.local.brad" }, + local: { + name: "Timeline Report (Local)", + baseUrl: "https://0342-68-187-209-164.ngrok-free.app", + key: "bitovi.timeline-report.local", + }, staging: { name: "Timeline Report (Staging)", baseUrl: "https://timeline-report-staging.bitovi-jira.com", @@ -34,7 +38,7 @@ function main() { [ `Specified environment ${environment} does not exist.`, "The only allowed environemnts are 'local', 'staging', or 'production' ", - ].join("\n") + ].join("\n"), ); process.exit(1); } @@ -45,7 +49,7 @@ function main() { fs.writeFileSync( path.resolve(__dirname, "../../", "public/atlassian-connect.json"), - JSON.stringify({ ...baseConnect, ...metadata, ...createModules(metadata) }) + JSON.stringify({ ...baseConnect, ...metadata, ...createModules(metadata) }), ); console.log("Created atlassian-connect.json"); diff --git a/vite.config.ts b/vite.config.ts index f01b691a..4ded757f 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -7,7 +7,7 @@ export default defineConfig({ setupFiles: "./vitest.setup.ts", globals: true, alias: { - '@routing-observable': import.meta.dirname + '/public/shared/route-pushstate' - } + "@routing-observable": import.meta.dirname + "/public/shared/route-pushstate", + }, }, }); From 8812c7a154692650f1eabee5580fa8ed4586b607 Mon Sep 17 00:00:00 2001 From: Bradley Momberger Date: Fri, 20 Dec 2024 02:33:03 -0500 Subject: [PATCH 6/6] [TR-79] move React history hooks and components to more sensible locations --- public/react/SaveReports/SaveReports.tsx | 2 +- .../SaveReports/hooks/useSelectedReports/useSelectedReport.ts | 2 +- public/react/Stats/Stats.tsx | 2 +- public/react/ViewReports/ViewReports.tsx | 4 ++-- public/react/ViewReports/ViewReportsWrapper.tsx | 2 +- .../components.ts => react/components/RoutingLink/index.tsx} | 3 ++- public/{jira/history/hooks.ts => react/hooks/history.ts} | 0 7 files changed, 8 insertions(+), 7 deletions(-) rename public/{jira/history/components.ts => react/components/RoutingLink/index.tsx} (94%) rename public/{jira/history/hooks.ts => react/hooks/history.ts} (100%) diff --git a/public/react/SaveReports/SaveReports.tsx b/public/react/SaveReports/SaveReports.tsx index ab929a13..c5d02d35 100644 --- a/public/react/SaveReports/SaveReports.tsx +++ b/public/react/SaveReports/SaveReports.tsx @@ -12,7 +12,7 @@ import ReportControls from "./components/ReportControls"; import EditableTitle from "./components/EditableTitle"; import { useSelectedReport } from "./hooks/useSelectedReports"; import routeDataObservable, { pushStateObservable as queryParamObservable } from "@routing-observable"; -import { useHistoryState, useHistoryStateValue, useHistoryValueCallback } from "../../jira/history/hooks"; +import { useHistoryState, useHistoryStateValue, useHistoryValueCallback } from "../hooks/history"; import { param } from "../../can"; export interface SaveReportProps { diff --git a/public/react/SaveReports/hooks/useSelectedReports/useSelectedReport.ts b/public/react/SaveReports/hooks/useSelectedReports/useSelectedReport.ts index b57b7c11..b1c43e35 100644 --- a/public/react/SaveReports/hooks/useSelectedReports/useSelectedReport.ts +++ b/public/react/SaveReports/hooks/useSelectedReports/useSelectedReport.ts @@ -4,7 +4,7 @@ import { useMemo, useState } from "react"; import { CanObservable } from "../../../hooks/useCanObservable"; import { useUpdateReport } from "../../../services/reports"; import { getReportFromParams, paramsMatchReport } from "./utilities"; -import { useHistoryCallback, useHistoryParams, useHistoryState } from "../../../../jira/history/hooks"; +import { useHistoryCallback, useHistoryParams, useHistoryState } from "../../../hooks/history"; import routeDataObservable from "@routing-observable"; import { param } from "../../../../can"; diff --git a/public/react/Stats/Stats.tsx b/public/react/Stats/Stats.tsx index 7b160111..8a02c422 100644 --- a/public/react/Stats/Stats.tsx +++ b/public/react/Stats/Stats.tsx @@ -2,7 +2,7 @@ import type { FC } from "react"; import React, { useState, useEffect } from "react"; import type { DerivedIssue } from "../../jira/derived/derive"; -import { useHistoryStateValue } from "../../jira/history/hooks"; +import { useHistoryStateValue } from "../hooks/history"; import { jStat } from "jstat"; diff --git a/public/react/ViewReports/ViewReports.tsx b/public/react/ViewReports/ViewReports.tsx index 90e6a1de..3a4c3992 100644 --- a/public/react/ViewReports/ViewReports.tsx +++ b/public/react/ViewReports/ViewReports.tsx @@ -11,9 +11,9 @@ import { IconButton } from "@atlaskit/button/new"; import ViewReportsLayout from "./components/ViewReportsLayout"; import { useAllReports, useDeleteReport, useRecentReports } from "../services/reports"; import DeleteReportModal from "./components/DeleteReportModal"; -import { RoutingLink } from "../../jira/history/components"; +import { RoutingLink } from "../components/RoutingLink"; import routeDataObservable from "@routing-observable"; -import { useHistoryStateValue } from "../../jira/history/hooks"; +import { useHistoryStateValue } from "../hooks/history"; interface ViewReportProps { onBackButtonClicked: () => void; diff --git a/public/react/ViewReports/ViewReportsWrapper.tsx b/public/react/ViewReports/ViewReportsWrapper.tsx index 38cbcfff..a3208dee 100644 --- a/public/react/ViewReports/ViewReportsWrapper.tsx +++ b/public/react/ViewReports/ViewReportsWrapper.tsx @@ -14,7 +14,7 @@ import ViewReportLayout from "./components/ViewReportsLayout"; import Skeleton from "../components/Skeleton"; import { StorageProvider } from "../services/storage"; import { useCanObservable } from "../hooks/useCanObservable"; -import { useHistoryStateValue } from "../../jira/history/hooks"; +import { useHistoryStateValue } from "../hooks/history"; import { FlagsProvider } from "@atlaskit/flag"; import { queryClient } from "../services/query"; diff --git a/public/jira/history/components.ts b/public/react/components/RoutingLink/index.tsx similarity index 94% rename from public/jira/history/components.ts rename to public/react/components/RoutingLink/index.tsx index a89d125e..117f2342 100644 --- a/public/jira/history/components.ts +++ b/public/react/components/RoutingLink/index.tsx @@ -1,7 +1,7 @@ import { MouseEvent, PropsWithChildren, default as React } from "react"; import routeDataObservable from "@routing-observable"; -import { deparam, param } from "../../can"; +import { deparam, param } from "../../../can"; interface RoutingLinkPropsBase extends PropsWithChildren { as?: "a" | "button"; @@ -40,3 +40,4 @@ export const RoutingLink = ({ as = "a", replaceAll, children, className, ...rest className, }); }; +export default RoutingLink; diff --git a/public/jira/history/hooks.ts b/public/react/hooks/history.ts similarity index 100% rename from public/jira/history/hooks.ts rename to public/react/hooks/history.ts