diff --git a/package-lock.json b/package-lock.json index 31d94c38..12219554 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,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", @@ -4720,6 +4721,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 b70b364d..c131f51c 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,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/can.js b/public/can.js index 72fd11a4..df776b85 100644 --- a/public/can.js +++ b/public/can.js @@ -44560,4 +44560,4 @@ if (process.env.NODE_ENV !== 'production') { //!steal-remove-end export default canNamespace_1_0_0_canNamespace; -export { canValue_1_1_2_canValue as value, canObservation_4_2_0_canObservation as Observation, canObservationRecorder_1_3_1_canObservationRecorder as ObservationRecorder, canSimpleMap_4_3_3_canSimpleMap as SimpleMap, canObservableObject as ObservableObject, canObservableArray as ObservableArray, canObservableBindings_1_3_3_fromAttribute as fromAttribute, canBind_1_5_1_canBind as bind, map$1 as mapEventBindings, value as valueEventBindings, canSimpleObservable_2_5_0_canSimpleObservable as SimpleObservable, async as AsyncObservable, key as keyObservable, resolver as ResolverObservable, settable as SettableObservable, setter as SetterObservable, canStacheElement as StacheElement, canStache_5_1_1_canStache as stache, canStacheBindings_5_0_5_canStacheBindings as stacheBindings, canStacheRouteHelpers_2_0_0_canStacheRouteHelpers as stacheRouteHelpers, canViewCallbacks_5_0_0_canViewCallbacks as viewCallbacks, canViewLive_5_0_5_canViewLive as viewLive, canViewModel_4_0_3_canViewModel as viewModel, canViewParser_4_1_3_canViewParser as viewParser, canViewScope_4_13_7_canViewScope as Scope, canViewTarget_5_0_0_canViewTarget as target, canFixture_3_1_7_fixture as fixture, canQueryLogic_1_2_4_canQueryLogic as QueryLogic, canRealtimeRestModel_2_0_0_canRealtimeRestModel as realtimeRestModel, canRestModel_2_0_0_canRestModel as restModel, canConnect_4_0_6_all as connect, canLocalStore_1_0_1_canLocalStore as localStore, canMemoryStore_1_0_3_canMemoryStore as memoryStore, canRoute_5_0_2_canRoute as route, canRouteHash_1_0_2_canRouteHash as RouteHash, canRoutePushstate_6_0_0_canRoutePushstate as RoutePushstate, canParam_1_2_0_canParam as param, canDeparam_1_2_3_canDeparam as deparam, canAssign_1_3_3_canAssign as assign, canDefineLazyValue_1_1_1_defineLazyValue as defineLazyValue, canDiff_1_5_1_canDiff as diff, canGlobals_1_2_2_canGlobals as globals, canKey_1_2_1_canKey as key, canKeyTree_1_2_2_canKeyTree as KeyTree, canMakeMap_1_2_2_canMakeMap as makeMap, canParseUri_1_2_2_canParseUri as parseURI, canQueues_1_3_2_canQueues as queues, canString_1_1_0_canString as string, canStringToAny_1_2_1_canStringToAny as stringToAny, canAjax_2_4_8_canAjax as ajax, canAttributeEncoder_1_1_4_canAttributeEncoder as attributeEncoder, canChildNodes_1_2_1_canChildNodes as childNodes, canDomData_1_0_3_canDomData as domData, canDomEvents_1_3_13_canDomEvents as domEvents, addJqueryEvents as addJQueryEvents, canDomMutate_2_0_9_canDomMutate as domMutate, canDomMutate_2_0_9_node as domMutateNode, canDomMutate_2_0_9_domEvents as domMutateDomEvents, canFragment_1_3_1_canFragment as fragment, canValidateInterface_1_0_3_index as makeInterfaceValidator, canCid_1_3_1_canCid as cid, canConstruct_3_5_7_canConstruct as Construct, maybeBoolean as MaybeBoolean, maybeDate as MaybeDate, maybeNumber as MaybeNumber, maybeString as MaybeString, canNamespace_1_0_0_canNamespace as can, canReflect_1_19_2_canReflect as Reflect, canReflectDependencies_1_1_2_canReflectDependencies as reflectDependencies, canReflectPromise_2_2_1_canReflectPromise as reflectPromise, canType_1_1_6_canType as type }; \ No newline at end of file +export { canValue_1_1_2_canValue as value, canObservation_4_2_0_canObservation as Observation, canObservationRecorder_1_3_1_canObservationRecorder as ObservationRecorder, canSimpleMap_4_3_3_canSimpleMap as SimpleMap, canObservableObject as ObservableObject, canObservableArray as ObservableArray, canObservableBindings_1_3_3_fromAttribute as fromAttribute, canBind_1_5_1_canBind as bind, map$1 as mapEventBindings, value as valueEventBindings, canSimpleObservable_2_5_0_canSimpleObservable as SimpleObservable, async as AsyncObservable, key as keyObservable, resolver as ResolverObservable, settable as SettableObservable, setter as SetterObservable, canStacheElement as StacheElement, canStache_5_1_1_canStache as stache, canStacheBindings_5_0_5_canStacheBindings as stacheBindings, canStacheRouteHelpers_2_0_0_canStacheRouteHelpers as stacheRouteHelpers, canViewCallbacks_5_0_0_canViewCallbacks as viewCallbacks, canViewLive_5_0_5_canViewLive as viewLive, canViewModel_4_0_3_canViewModel as viewModel, canViewParser_4_1_3_canViewParser as viewParser, canViewScope_4_13_7_canViewScope as Scope, canViewTarget_5_0_0_canViewTarget as target, canFixture_3_1_7_fixture as fixture, canQueryLogic_1_2_4_canQueryLogic as QueryLogic, canRealtimeRestModel_2_0_0_canRealtimeRestModel as realtimeRestModel, canRestModel_2_0_0_canRestModel as restModel, canConnect_4_0_6_all as connect, canLocalStore_1_0_1_canLocalStore as localStore, canMemoryStore_1_0_3_canMemoryStore as memoryStore, canRoute_5_0_2_canRoute as route, canRouteHash_1_0_2_canRouteHash as RouteHash, canRoutePushstate_6_0_0_canRoutePushstate as RoutePushstate, canParam_1_2_0_canParam as param, canDeparam_1_2_3_canDeparam as deparam, canAssign_1_3_3_canAssign as assign, canDefineLazyValue_1_1_1_defineLazyValue as defineLazyValue, canDiff_1_5_1_canDiff as diff, canGlobals_1_2_2_canGlobals as globals, canKey_1_2_1_canKey as key, canKeyTree_1_2_2_canKeyTree as KeyTree, canMakeMap_1_2_2_canMakeMap as makeMap, canParseUri_1_2_2_canParseUri as parseURI, canQueues_1_3_2_canQueues as queues, canString_1_1_0_canString as string, canStringToAny_1_2_1_canStringToAny as stringToAny, canAjax_2_4_8_canAjax as ajax, canAttributeEncoder_1_1_4_canAttributeEncoder as attributeEncoder, canChildNodes_1_2_1_canChildNodes as childNodes, canDomData_1_0_3_canDomData as domData, canDomEvents_1_3_13_canDomEvents as domEvents, addJqueryEvents as addJQueryEvents, canDomMutate_2_0_9_canDomMutate as domMutate, canDomMutate_2_0_9_node as domMutateNode, canDomMutate_2_0_9_domEvents as domMutateDomEvents, canFragment_1_3_1_canFragment as fragment, canValidateInterface_1_0_3_index as makeInterfaceValidator, canCid_1_3_1_canCid as cid, canConstruct_3_5_7_canConstruct as Construct, maybeBoolean as MaybeBoolean, maybeDate as MaybeDate, maybeNumber as MaybeNumber, maybeString as MaybeString, canNamespace_1_0_0_canNamespace as can, canReflect_1_19_2_canReflect as Reflect, canReflectDependencies_1_1_2_canReflectDependencies as reflectDependencies, canReflectPromise_2_2_1_canReflectPromise as reflectPromise, canType_1_1_6_canType as type }; diff --git a/public/jira/history/observable.ts b/public/jira/history/observable.ts new file mode 100644 index 00000000..dde35929 --- /dev/null +++ b/public/jira/history/observable.ts @@ -0,0 +1,412 @@ +import { deparam as _deparam } from "../../can"; +const deparam = _deparam as (params?: string | null) => Record; + +interface JiraLocationState { + key: string; + hash: string | null; + query?: { + state?: string; + }; + state?: Record | null; + title: string; + href: string; +} + +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: AddHandlerWithOptionalKey; + off: AddHandlerWithOptionalKey; + get: ObjectGetter; + set: (...args: [string] | [string, string | null] | [state: Record]) => void; + value: string; +} + +interface AddHandlerWithOptionalKey { + (handler: KeyHandler, queue?: string): void; + (key: string | undefined, handler: KeyHandler, queue?: string): void; +} + +interface ObjectGetter { + (): Record; + (key: undefined): Record; + (key: string): string | undefined; +} + +const handlers = new Map>(); +const patchHandlers = new Map(); +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 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; + handler = key; + key = undefined; + } + + 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); + } + } 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") { + patchHandlers.delete(handler); + } else if (handlers.has(key)) { + const keyHandlers = handlers.get(key)!; + 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) { + return params.get(key!); + } else { + 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") ?? {}; + const { state: currentState = "" } = query ?? {}; + const currentParams = deparam(decodeURIComponent(currentState)); + let newParams: Record = { ...currentParams }; + let newState: 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; + } else if (val == null) { + delete newParams[keyOrState]; + } else { + newParams[keyOrState] = val; + } + } else { + Object.entries(keyOrState).forEach(([key, val]) => { + if (val == null) { + delete newParams[key]; + } else { + newParams[key] = val; + } + }); + } + keyblocklist.forEach((key) => { + delete newParams[key]; + }); + + 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: { state: encodeURIComponent(newURLParams.toString()) }, + state: { fromPopState: "false" }, + }); + }, +}; + +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); + }); + } +}; + +/** + * 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"), + [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 + // history state. + get value() { + const params = this.get(); + const searchString = new URLSearchParams(params); + searchString.sort(); + return searchString.toString(); + }, + set value(params: string) { + this.set(params); + }, +}; + +/** + * 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; + } + 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"; + + 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); + } + }); + } + dispatchPatchHandlers(queryParams, lastQuery); + + 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(decodedQuery, lastQueryParams); + }); + + lastQuery = decodedQuery ? queryParams : 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); + } + }); + currentParams.sort(); + const paramString = currentParams.toString(); + keyblocklist.forEach((key) => currentParams.delete(key)); + + if (!disablePushState) { + history.pushState(searchParamsToObject(currentParams), "", `?${paramString}`); + } +}); + +/** + * 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); + searchParams.sort(); + keyblocklist.forEach((key) => searchParams.delete(key)); + + 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 ?? ""); + 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; +export const pushStateObservable = browserState; 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 215e734d..c5d02d35 100644 --- a/public/react/SaveReports/SaveReports.tsx +++ b/public/react/SaveReports/SaveReports.tsx @@ -10,15 +10,16 @@ import SaveReportModal from "./components/SaveReportModal"; import SavedReportDropdown from "./components/SavedReportDropdown"; import ReportControls from "./components/ReportControls"; import EditableTitle from "./components/EditableTitle"; -import { useQueryParams } from "../hooks/useQueryParams"; import { useSelectedReport } from "./hooks/useSelectedReports"; +import routeDataObservable, { pushStateObservable as queryParamObservable } from "@routing-observable"; +import { useHistoryState, useHistoryStateValue, useHistoryValueCallback } from "../hooks/history"; +import { param } from "../../can"; -interface SaveReportProps { +export 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); @@ -28,11 +29,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; @@ -43,17 +45,14 @@ const SaveReport: FC = ({ queryParamObservable, onViewReportsBu const { recentReports, addReportToRecents } = useRecentReports(); - const { queryParams } = useQueryParams(queryParamObservable, { - onChange: (params) => { - const report = params.get("report"); - - // TODO: If confirm `report` exists in `reports` before adding - // TODO: Reconcile deleted reports with whats there + const [queryParams] = useHistoryState(); + useHistoryValueCallback("report", (report: string | undefined) => { + // 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) => { @@ -67,11 +66,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(); @@ -81,7 +82,7 @@ const SaveReport: FC = ({ queryParamObservable, onViewReportsBu url.searchParams.set("report", id); queryParamObservable.set(url.search); }, - } + }, ); }; @@ -114,7 +115,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 e5a46a8b..c49a2641 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,14 +17,7 @@ describe("", () => { update: vi.fn(), }} onViewReportsButtonClicked={mockOnViewReportsButtonClicked} - queryParamObservable={{ - on: vi.fn(), - off: vi.fn(), - getData: vi.fn(), - value: "", - set: vi.fn(), - }} - /> + />, ); await waitFor(() => { @@ -34,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(), - getData: vi.fn(), - value: "?jql=issues-and-what-not", - set: vi.fn(), - }} - /> + />, ); await waitFor(() => { diff --git a/public/react/SaveReports/SaveReportsWrapper.tsx b/public/react/SaveReports/SaveReportsWrapper.tsx index b897ba14..a2bcf04d 100644 --- a/public/react/SaveReports/SaveReportsWrapper.tsx +++ b/public/react/SaveReports/SaveReportsWrapper.tsx @@ -10,13 +10,11 @@ import { FlagsProvider } from "@atlaskit/flag"; import { StorageProvider } from "../services/storage"; import Skeleton from "../components/Skeleton"; -import SaveReports from "./SaveReports"; +import SaveReports, { SaveReportProps } from "./SaveReports"; import { queryClient } from "../services/query"; -interface SaveReportsWrapperProps { +interface SaveReportsWrapperProps extends SaveReportProps { storage: AppStorage; - onViewReportsButtonClicked: () => void; - queryParamObservable: CanObservable; } const SaveReportsWrapper: FC = ({ storage, ...saveReportProps }) => { diff --git a/public/react/SaveReports/components/SavedReportDropdown/RecentReports.tsx b/public/react/SaveReports/components/SavedReportDropdown/RecentReports.tsx index 35740dae..180652b7 100644 --- a/public/react/SaveReports/components/SavedReportDropdown/RecentReports.tsx +++ b/public/react/SaveReports/components/SavedReportDropdown/RecentReports.tsx @@ -5,6 +5,7 @@ import React, { useMemo } from "react"; import { DropdownItem, DropdownItemGroup } from "@atlaskit/dropdown-menu"; import Hr from "../../../components/Hr"; +import { pushStateObservable } from "@routing-observable"; import { notEmpty } from "../../../../jira/shared/helpers"; interface RecentReportsProps { diff --git a/public/react/SaveReports/hooks/useSelectedReports/useSelectedReport.ts b/public/react/SaveReports/hooks/useSelectedReports/useSelectedReport.ts index cd41c13d..b1c43e35 100644 --- a/public/react/SaveReports/hooks/useSelectedReports/useSelectedReport.ts +++ b/public/react/SaveReports/hooks/useSelectedReports/useSelectedReport.ts @@ -1,37 +1,33 @@ 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 "../../../hooks/history"; +import routeDataObservable from "@routing-observable"; +import { param } from "../../../../can"; -export const useSelectedReport = ({ - reports, - queryParamObservable, -}: { - queryParamObservable: CanObservable; - reports: Reports; -}) => { +export const useSelectedReport = ({ reports }: { reports: Reports }) => { const { updateReport } = useUpdateReport(); const [selectedReport, setSelectedReport] = useState(() => - getReportFromParams(reports) + getReportFromParams(reports), ); - const [isDirty, setIsDirty] = useState( - () => !paramsMatchReport(new URLSearchParams(window.location.search), reports) - ); + const [initial] = useHistoryState(); - useQueryParams(queryParamObservable, { - onChange: (params) => { - const newSelectedReport = getReportFromParams(reports); + const [search] = useHistoryParams(); - if (newSelectedReport?.id !== selectedReport?.id) { - setSelectedReport(newSelectedReport); - } + const [isDirty, setIsDirty] = useState(() => !paramsMatchReport(new URLSearchParams(search), reports)); - setIsDirty(() => !paramsMatchReport(params, reports)); - }, + useHistoryCallback((params) => { + const newSelectedReport = getReportFromParams(reports); + + if (newSelectedReport?.id !== selectedReport?.id) { + setSelectedReport(newSelectedReport); + } + + setIsDirty(() => !paramsMatchReport(new URLSearchParams(params), reports)); }); return { @@ -42,14 +38,13 @@ 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() }, - { onSuccess: () => setIsDirty(false) } + { queryParams: param(queryParams) }, + { onSuccess: () => setIsDirty(false) }, ); }, isDirty, diff --git a/public/react/SaveReports/hooks/useSelectedReports/utilities.test.ts b/public/react/SaveReports/hooks/useSelectedReports/utilities.test.ts index bba2706a..8c35b14b 100644 --- a/public/react/SaveReports/hooks/useSelectedReports/utilities.test.ts +++ b/public/react/SaveReports/hooks/useSelectedReports/utilities.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { paramsEqual, getReportFromParams, paramsMatchReport } from "./utilities"; +import routeDataObservable, { pushStateObservable } from "@routing-observable"; const mockReports = { "1": { id: "1", name: "Report 1", queryParams: "param1=value1¶m2=value2" }, @@ -56,21 +57,21 @@ describe("useSelectedReports > utilities", () => { describe("getReportFromParams", () => { it("returns the correct report based on URLSearchParams", () => { - window.location.search = "?report=1"; + pushStateObservable.set("?report=1"); const report = getReportFromParams(mockReports); expect(report).toEqual(mockReports["1"]); }); it("returns undefined if the 'report' param is missing", () => { - window.location.search = "?otherParam=1"; + pushStateObservable.set("?otherParam=1"); const report = getReportFromParams(mockReports); expect(report).toBeUndefined(); }); it("returns undefined if no matching report is found", () => { - window.location.search = "?report=nonexistent"; + pushStateObservable.set("?report=nonexistent"); const report = getReportFromParams(mockReports); expect(report).toBeUndefined(); @@ -79,28 +80,28 @@ describe("useSelectedReports > utilities", () => { describe("paramsMatchReport", () => { it("returns true when params match the report's queryParams", () => { - window.location.search = "?report=1"; + pushStateObservable.set("report=1"); const params = new URLSearchParams("param1=value1¶m2=value2"); expect(paramsMatchReport(params, mockReports)).toBe(true); }); it("returns false when params do not match the report's queryParams", () => { - window.location.search = "?report=1"; + pushStateObservable.set("report=1"); const params = new URLSearchParams("param1=value1¶m2=differentValue"); expect(paramsMatchReport(params, mockReports)).toBe(false); }); it("ignores params specified in paramsToOmit", () => { - window.location.search = "?report=1"; + pushStateObservable.set("report=1"); const params = new URLSearchParams("param1=value1¶m2=value2&settings=value3"); expect(paramsMatchReport(params, mockReports, ["settings"])).toBe(true); }); it("returns false if no matching report is found", () => { - window.location.search = "?report=nonexistent"; + pushStateObservable.set("report=nonexistent"); const params = new URLSearchParams("param1=value1¶m2=value2"); expect(paramsMatchReport(params, mockReports)).toBe(false); diff --git a/public/react/SaveReports/hooks/useSelectedReports/utilities.ts b/public/react/SaveReports/hooks/useSelectedReports/utilities.ts index dc4d80f5..fc1fb496 100644 --- a/public/react/SaveReports/hooks/useSelectedReports/utilities.ts +++ b/public/react/SaveReports/hooks/useSelectedReports/utilities.ts @@ -1,4 +1,5 @@ import type { Reports, Report } from "../../../../jira/reports"; +import routeDataObservable from "@routing-observable"; export const paramsEqual = (lhs: URLSearchParams, rhs: URLSearchParams): boolean => { const lhsEntries = [...lhs.entries()]; @@ -9,16 +10,12 @@ 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); }; export const getReportFromParams = (reports: Reports): Report | undefined => { - const params = new URLSearchParams(window.location.search); - const selectedReport = params.get("report"); + const selectedReport = routeDataObservable.get("report"); if (!selectedReport) { return; @@ -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 0f5fea22..8a02c422 100644 --- a/public/react/Stats/Stats.tsx +++ b/public/react/Stats/Stats.tsx @@ -2,161 +2,162 @@ import type { FC } from "react"; import React, { useState, useEffect } from "react"; import type { DerivedIssue } from "../../jira/derived/derive"; +import { useHistoryStateValue } from "../hooks/history"; -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 ( - <> - {round( sum ) } - {round( mean ) } - {round(median)} - {round(stdDev, 3)} - - ) -} +const NormalDataOutput: FC> = ({ mean, stdDev, median, sum }) => { + return ( + <> + {round(sum)} + {round(mean)} + {round(median)} + {round(stdDev, 3)} + + ); +}; 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); - if(!issues?.length) { - return
Loading ...
- } - - const allTeamNames = getTeamNames(issues) - - return ( -
-
{issues.length} items
- - - - - {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; \ No newline at end of file +export default ConfigurationPanel; diff --git a/public/react/ViewReports/ViewReport.test.tsx b/public/react/ViewReports/ViewReport.test.tsx index 66ae4791..9c831713 100644 --- a/public/react/ViewReports/ViewReport.test.tsx +++ b/public/react/ViewReports/ViewReport.test.tsx @@ -9,6 +9,7 @@ import { FlagsProvider } from "@atlaskit/flag"; import userEvent from "@testing-library/user-event"; import ViewReports from "./ViewReports"; +import { pushStateObservable } from "@routing-observable"; import { StorageProvider } from "../services/storage"; type OverrideStorage = Omit & { @@ -101,10 +102,7 @@ describe("ViewReports Component", () => { }); it("renders the selected report's name in the reportInfo section", async () => { - Object.defineProperty(window, "location", { - writable: true, - value: { search: "?report=1" }, - }); + pushStateObservable.set("?report=1"); renderWithWrappers({ storage: { diff --git a/public/react/ViewReports/ViewReports.tsx b/public/react/ViewReports/ViewReports.tsx index 6a0b3692..3a4c3992 100644 --- a/public/react/ViewReports/ViewReports.tsx +++ b/public/react/ViewReports/ViewReports.tsx @@ -11,6 +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 "../components/RoutingLink"; +import routeDataObservable from "@routing-observable"; +import { useHistoryStateValue } from "../hooks/history"; interface ViewReportProps { onBackButtonClicked: () => void; @@ -24,20 +27,18 @@ const ViewReports: FC = ({ onBackButtonClicked }) => { const { removeFromRecentReports } = useRecentReports(); - 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) @@ -49,12 +50,13 @@ const ViewReports: FC = ({ onBackButtonClicked }) => { { key: `${report.id}-report`, content: ( - {report.name} - + ), }, { @@ -89,7 +91,7 @@ const ViewReports: FC = ({ onBackButtonClicked }) => { <> {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/components/RoutingLink/index.tsx b/public/react/components/RoutingLink/index.tsx new file mode 100644 index 00000000..117f2342 --- /dev/null +++ b/public/react/components/RoutingLink/index.tsx @@ -0,0 +1,43 @@ +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, + }); +}; +export default RoutingLink; diff --git a/public/react/hooks/history.ts b/public/react/hooks/history.ts new file mode 100644 index 00000000..8b52d714 --- /dev/null +++ b/public/react/hooks/history.ts @@ -0,0 +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]); +}; diff --git a/public/react/hooks/useCanObservable/useCanObservable.ts b/public/react/hooks/useCanObservable/useCanObservable.ts index f15b7020..450da262 100644 --- a/public/react/hooks/useCanObservable/useCanObservable.ts +++ b/public/react/hooks/useCanObservable/useCanObservable.ts @@ -2,7 +2,6 @@ import { useEffect, useState } from "react"; export interface CanObservable { value: TData; - getData(): TData; on(handler: () => void): void; off(handler: () => void): void; set(value: TData): void; 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/public/reports/table-grid.js b/public/reports/table-grid.js index 2169cb27..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,171 +253,196 @@ 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()) { - createRoot(document.getElementById("stats")).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() { + if (this.statsRoot) { + this.statsRoot.unmount(); + this.statsRoot = null; + } + } } 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/main-helper.js b/public/shared/main-helper.js index 3b09804f..5b7f7120 100644 --- a/public/shared/main-helper.js +++ b/public/shared/main-helper.js @@ -32,7 +32,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..df166752 --- /dev/null +++ b/public/shared/route-pushstate.ts @@ -0,0 +1,55 @@ +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; diff --git a/public/shared/state-storage.js b/public/shared/state-storage.js index 794c1ce6..953cc935 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,12 +15,7 @@ export function saveToLocalStorage(key, defaultValue) { }; } -const underlyingReplaceState = history.replaceState; - -export const pushStateObservable = new RoutePushstate(); -route.urlData = pushStateObservable; -route.urlData.root = window.location.pathname; -route.register(""); +export { routeObservable as pushStateObservable }; const dateMatch = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; @@ -106,4 +103,4 @@ export function updateUrlParam(key, valueJSON, defaultJSON) { } pushStateObservable.value = newUrl.search; //history.pushState({}, '', ); -} +} \ No newline at end of file diff --git a/rollup.config.mjs b/rollup.config.mjs index 87e3a8a7..2448fcf9 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -3,11 +3,19 @@ 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 babelDev = { + exclude: "node_modules/**", + plugins: ["@babel/plugin-transform-react-jsx-development"], + babelHelpers: "bundled", }; const warn = { @@ -21,6 +29,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", @@ -39,14 +60,11 @@ export default [ inlineDynamicImports: true, }, plugins: [ + alias(aliases.hosted), nodeResolve(), commonjs(), typescript(), - babel({ - exclude: "node_modules/**", - plugins: ["@babel/plugin-transform-react-jsx-development"], - babelHelpers: "bundled", - }), + babel(babelDev), ], ...warn, }, @@ -57,7 +75,7 @@ export default [ format: "esm", inlineDynamicImports: true, }, - plugins: [nodeResolve(), commonjs(), terser(), typescript(), babel(babelProd)], + plugins: [alias(aliases.hosted), nodeResolve(), commonjs(), terser(), typescript(), babel(babelProd)], ...warn, }, { @@ -67,7 +85,7 @@ export default [ format: "esm", inlineDynamicImports: true, }, - plugins: [nodeResolve(), commonjs(), terser(), typescript(), babel(babelProd)], + plugins: [alias(aliases.connect), nodeResolve(), commonjs(), terser(), typescript(), babel(babelProd)], ...warn, }, ]; diff --git a/scripts/atlassian-connect/index.ts b/scripts/atlassian-connect/index.ts index b7529a3d..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)", baseUrl: "", key: "bitovi.timeline-report.local" }, + 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"); @@ -57,7 +61,7 @@ function main() { main(); -function createModules({ name }: Metadata) { +function createModules({ name, key }: Metadata) { return { modules: { generalPages: [ @@ -70,7 +74,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: { diff --git a/tsconfig.json b/tsconfig.json index 9e6ba804..7d68b36f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -22,6 +22,10 @@ "paths": { "undici-types": [ "./node_modules/undici/types" + ], + "@routing-observable": [ + "./public/jira/history/observable", + "./public/shared/route-pushstate" ] }, "sourceMap": false, diff --git a/vite.config.ts b/vite.config.ts index b65a5308..4ded757f 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -6,5 +6,8 @@ export default defineConfig({ globalSetup: "./vitest-global.ts", setupFiles: "./vitest.setup.ts", globals: true, + alias: { + "@routing-observable": import.meta.dirname + "/public/shared/route-pushstate", + }, }, }); diff --git a/vitest.setup.ts b/vitest.setup.ts index d95b4915..262a801a 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -1,5 +1,6 @@ import "@testing-library/jest-dom"; import { vi } from "vitest"; +import { globals } from "./public/can"; window.matchMedia = vi.fn().mockImplementation((query) => ({ matches: vi.fn(), @@ -11,3 +12,5 @@ window.matchMedia = vi.fn().mockImplementation((query) => ({ removeEventListener: vi.fn(), dispatchEvent: vi.fn(), })); + +globals.setKeyValue("isNode", false);