diff --git a/packages/frontend/navi/src/action/action_private_properties.js b/packages/frontend/navi/src/action/action_private_properties.js index 7de1757f3f..471c88f867 100644 --- a/packages/frontend/navi/src/action/action_private_properties.js +++ b/packages/frontend/navi/src/action/action_private_properties.js @@ -1,3 +1,5 @@ +import { ABORTED, COMPLETED, IDLE, RUNNING } from "./action_run_states.js"; + const actionPrivatePropertiesWeakMap = new WeakMap(); export const getActionPrivateProperties = (action) => { const actionPrivateProperties = actionPrivatePropertiesWeakMap.get(action); @@ -9,3 +11,17 @@ export const getActionPrivateProperties = (action) => { export const setActionPrivateProperties = (action, properties) => { actionPrivatePropertiesWeakMap.set(action, properties); }; + +export const getActionStatus = (action) => { + const { runningStateSignal, errorSignal, computedDataSignal } = + getActionPrivateProperties(action); + const runningState = runningStateSignal.value; + const idle = runningState === IDLE; + const aborted = runningState === ABORTED; + const error = errorSignal.value; + const loading = runningState === RUNNING; + const completed = runningState === COMPLETED; + const data = computedDataSignal.value; + + return { idle, loading, aborted, error, completed, data }; +}; diff --git a/packages/frontend/navi/src/action/actions.js b/packages/frontend/navi/src/action/actions.js index 7630e7cd5e..0a1dab4251 100644 --- a/packages/frontend/navi/src/action/actions.js +++ b/packages/frontend/navi/src/action/actions.js @@ -676,7 +676,7 @@ export const createAction = (callback, rootOptions = {}) => { } // ✅ CAS 2: Objet -> vérifier s'il contient des signals - if (newParamsOrSignal && typeof newParamsOrSignal === "object") { + if (isPlainObject(newParamsOrSignal)) { const staticParams = {}; const signalMap = new Map(); @@ -727,7 +727,7 @@ export const createAction = (callback, rootOptions = {}) => { return createActionProxyFromSignal(action, paramsSignal, options); } - // ✅ CAS 3: Primitive -> action enfant + // ✅ CAS 3: Primitive or objects like DOMEvents etc -> action enfant return createChildAction({ params: newParamsOrSignal, ...options, @@ -1395,6 +1395,19 @@ const generateActionName = (name, params) => { return `${name}${argsString}`; }; +const isPlainObject = (obj) => { + if (typeof obj !== "object" || obj === null) { + return false; + } + let proto = obj; + while (Object.getPrototypeOf(proto) !== null) { + proto = Object.getPrototypeOf(proto); + } + return ( + Object.getPrototypeOf(obj) === proto || Object.getPrototypeOf(obj) === null + ); +}; + if (import.meta.hot) { import.meta.hot.dispose(() => { abortRunningActions(); diff --git a/packages/frontend/navi/src/keyboard/keyboard_shortcuts.js b/packages/frontend/navi/src/keyboard/keyboard_shortcuts.js index ce4a537fd6..920231b78d 100644 --- a/packages/frontend/navi/src/keyboard/keyboard_shortcuts.js +++ b/packages/frontend/navi/src/keyboard/keyboard_shortcuts.js @@ -164,7 +164,8 @@ export const useKeyboardShortcuts = ( return false; } const { action } = shortcutCandidate; - return requestAction(element, action, { + const actionWithEvent = action.bindParams(keyboardEvent); + return requestAction(element, actionWithEvent, { actionOrigin: "keyboard_shortcut", event: keyboardEvent, requester: document.activeElement, diff --git a/packages/frontend/navi/src/layout/details/demos/details_demo.html b/packages/frontend/navi/src/layout/details/demos/details_demo.html new file mode 100644 index 0000000000..7e8d4e4425 --- /dev/null +++ b/packages/frontend/navi/src/layout/details/demos/details_demo.html @@ -0,0 +1,530 @@ + + + + + + + Details Component Demo + + + +

Details Component Demo

+
+ + + + diff --git a/packages/frontend/navi/src/layout/details/details.jsx b/packages/frontend/navi/src/layout/details/details.jsx index 43ff11bbe8..6c4a9b3de1 100644 --- a/packages/frontend/navi/src/layout/details/details.jsx +++ b/packages/frontend/navi/src/layout/details/details.jsx @@ -1,12 +1,12 @@ import { elementIsFocusable, findAfter } from "@jsenv/dom"; -import { forwardRef } from "preact/compat"; -import { useEffect, useImperativeHandle, useRef, useState } from "preact/hooks"; +import { useEffect, useRef, useState } from "preact/hooks"; import { ActionRenderer } from "../../action/action_renderer.jsx"; import { renderActionableComponent } from "../../action/render_actionable_component.jsx"; import { useAction } from "../../action/use_action.js"; import { useActionStatus } from "../../action/use_action_status.js"; import { useExecuteAction } from "../../action/use_execute_action.js"; +import { Box } from "../../box/box.jsx"; import { useActionEvents } from "../../field/use_action_events.js"; import { useFocusGroup } from "../../field/use_focus_group.js"; import { requestAction } from "../../field/validation/custom_constraint_validation.js"; @@ -21,49 +21,51 @@ import.meta.css = /* css */ ` display: flex; flex-shrink: 0; flex-direction: column; - } - .navi_details > summary { - display: flex; - flex-shrink: 0; - flex-direction: column; - cursor: pointer; - user-select: none; - } - .summary_body { - display: flex; - width: 100%; - flex-direction: row; - align-items: center; - gap: 0.2em; - } - .summary_label { - display: flex; - padding-right: 10px; - flex: 1; - align-items: center; - gap: 0.2em; - } + summary { + display: flex; + flex-shrink: 0; + flex-direction: column; + cursor: pointer; + user-select: none; - .navi_details > summary:focus { - z-index: 1; + &:focus { + z-index: 1; + } + + .navi_summary_body { + display: flex; + width: 100%; + flex-direction: row; + align-items: center; + gap: 0.2em; + + .navi_summary_label { + display: flex; + padding-right: 10px; + flex: 1; + align-items: center; + gap: 0.2em; + } + } + } } `; -export const Details = forwardRef((props, ref) => { - return renderActionableComponent(props, ref, { +export const Details = (props) => { + const details = renderActionableComponent(props, { Basic: DetailsBasic, WithAction: DetailsWithAction, }); -}); + return details; +}; -const DetailsBasic = forwardRef((props, ref) => { +const DetailsBasic = (props) => { const { id, label = "Summary", open, loading, - className, focusGroup, focusGroupDirection, arrowKeyShortcuts = true, @@ -73,12 +75,12 @@ const DetailsBasic = forwardRef((props, ref) => { children, ...rest } = props; - const innerRef = useRef(); - useImperativeHandle(ref, () => innerRef.current); + const defaultRef = useRef(); + const ref = rest.ref || defaultRef; const [navState, setNavState] = useNavState(id); const [innerOpen, innerOpenSetter] = useState(open || navState); - useFocusGroup(innerRef, { + useFocusGroup(ref, { enabled: focusGroup, name: typeof focusGroup === "string" ? focusGroup : undefined, direction: focusGroupDirection, @@ -100,7 +102,7 @@ const DetailsBasic = forwardRef((props, ref) => { */ const summaryRef = useRef(null); - useKeyboardShortcuts(innerRef, [ + useKeyboardShortcuts(ref, [ { key: openKeyShortcut, enabled: arrowKeyShortcuts, @@ -109,7 +111,7 @@ const DetailsBasic = forwardRef((props, ref) => { // avoid handling openKeyShortcut twice when keydown occurs inside nested details !e.defaultPrevented, action: (e) => { - const details = innerRef.current; + const details = ref.current; if (!details.open) { e.preventDefault(); details.open = true; @@ -132,11 +134,11 @@ const DetailsBasic = forwardRef((props, ref) => { key: closeKeyShortcut, enabled: arrowKeyShortcuts, when: () => { - const details = innerRef.current; + const details = ref.current; return details.open; }, action: (e) => { - const details = innerRef.current; + const details = ref.current; const summary = summaryRef.current; if (document.activeElement === summary) { e.preventDefault(); @@ -156,14 +158,12 @@ const DetailsBasic = forwardRef((props, ref) => { }, []); return ( -
{ const isOpen = e.newState === "open"; if (mountedRef.current) { @@ -180,17 +180,17 @@ const DetailsBasic = forwardRef((props, ref) => { open={innerOpen} > -
+
-
{label}
+
{label}
{children} -
+ ); -}); +}; -const DetailsWithAction = forwardRef((props, ref) => { +const DetailsWithAction = (props) => { const { action, loading, @@ -202,16 +202,16 @@ const DetailsWithAction = forwardRef((props, ref) => { children, ...rest } = props; - const innerRef = useRef(); - useImperativeHandle(ref, () => innerRef.current); + const defaultRef = useRef(); + const ref = rest.ref || defaultRef; const effectiveAction = useAction(action); const { loading: actionLoading } = useActionStatus(effectiveAction); - const executeAction = useExecuteAction(innerRef, { + const executeAction = useExecuteAction(ref, { // the error will be displayed by actionRenderer inside
errorEffect: "none", }); - useActionEvents(innerRef, { + useActionEvents(ref, { onPrevented: onActionPrevented, onAction: (e) => { executeAction(e); @@ -224,7 +224,7 @@ const DetailsWithAction = forwardRef((props, ref) => { return ( { const isOpen = toggleEvent.newState === "open"; @@ -242,4 +242,4 @@ const DetailsWithAction = forwardRef((props, ref) => { {children} ); -}); +}; diff --git a/packages/frontend/navi/src/nav/route.js b/packages/frontend/navi/src/nav/route.js index af9e4d848d..51bf8964cc 100644 --- a/packages/frontend/navi/src/nav/route.js +++ b/packages/frontend/navi/src/nav/route.js @@ -6,6 +6,7 @@ import { createPubSub } from "@jsenv/dom"; import { batch, computed, effect, signal } from "@preact/signals"; +import { getActionStatus } from "../action/action_private_properties.js"; import { compareTwoJsValues } from "../utils/compare_two_js_values.js"; import { resolveRouteUrl, setupPatterns } from "./route_pattern.js"; @@ -91,17 +92,29 @@ export const useRouteStatus = (route) => { `useRouteStatus() requires a route object, but received ${route}.`, ); } - const { urlSignal, matchingSignal, paramsSignal, visitedSignal } = route; + const { + urlSignal, + matchingSignal, + paramsSignal, + visitedSignal, + actionStatusSignal, + } = route; const url = urlSignal.value; const matching = matchingSignal.value; const params = paramsSignal.value; const visited = visitedSignal.value; + const { loading, aborted, error, completed, data } = actionStatusSignal.value; return { url, matching, params, visited, + loading, + aborted, + error, + completed, + data, }; }; @@ -153,6 +166,8 @@ export const updateRoutes = ( // state } = {}, ) => { + const returnValue = {}; + const routeMatchInfoSet = new Set(); for (const route of routeSet) { const routePrivateProperties = getRoutePrivateProperties(route); @@ -200,288 +215,292 @@ export const updateRoutes = ( }); } - // Apply all signal updates in a batch - const matchingRouteSet = new Set(); - batch(() => { - for (const { - route, - routePrivateProperties, - newMatching, - newParams, - } of routeMatchInfoSet) { - const { updateStatus } = routePrivateProperties; - const visited = isVisited(route.url); - updateStatus({ - matching: newMatching, - params: newParams, - visited, - }); - if (newMatching) { - matchingRouteSet.add(route); - } - } - + sync_routes_with_url: { // URL -> Signal synchronization (moved from individual route effects to eliminate circular dependency) // Prevent signal-to-URL synchronization during URL-to-signal synchronization isUpdatingRoutesFromUrl = true; + // Apply all signal updates in a batch + const matchingRouteSet = new Set(); + batch(() => { + for (const { + route, + routePrivateProperties, + newMatching, + newParams, + } of routeMatchInfoSet) { + const { updateStatus } = routePrivateProperties; + const visited = isVisited(route.url); + updateStatus({ + matching: newMatching, + params: newParams, + visited, + }); + if (newMatching) { + matchingRouteSet.add(route); + } + } - for (const { - route, - routePrivateProperties, - newMatching, - } of routeMatchInfoSet) { - const { routePattern } = routePrivateProperties; - const { connectionMap } = routePattern; - - for (const [paramName, connection] of connectionMap) { - const { signal: paramSignal, debug } = connection; - const rawParams = route.rawParamsSignal.value; - const urlParamValue = rawParams[paramName]; - - if (!newMatching) { - // Route doesn't match - check if any matching route extracts this parameter - let parameterExtractedByMatchingRoute = false; - let matchingRouteInSameFamily = false; + for (const { + route, + routePrivateProperties, + newMatching, + } of routeMatchInfoSet) { + const { routePattern } = routePrivateProperties; + const { connectionMap } = routePattern; + + for (const [paramName, connection] of connectionMap) { + const { signal: paramSignal, debug } = connection; + const rawParams = route.rawParamsSignal.value; + const urlParamValue = rawParams[paramName]; + + if (!newMatching) { + // Route doesn't match - check if any matching route extracts this parameter + let parameterExtractedByMatchingRoute = false; + let matchingRouteInSameFamily = false; + + for (const otherRoute of routeSet) { + if (otherRoute === route || !otherRoute.matching) { + continue; + } + const otherRawParams = otherRoute.rawParamsSignal.value; + const otherRoutePrivateProperties = + getRoutePrivateProperties(otherRoute); - for (const otherRoute of routeSet) { - if (otherRoute === route || !otherRoute.matching) { - continue; - } - const otherRawParams = otherRoute.rawParamsSignal.value; - const otherRoutePrivateProperties = - getRoutePrivateProperties(otherRoute); + // Check if this matching route extracts the parameter + if (paramName in otherRawParams) { + parameterExtractedByMatchingRoute = true; + } - // Check if this matching route extracts the parameter - if (paramName in otherRawParams) { - parameterExtractedByMatchingRoute = true; - } + // Check if this matching route is in the same family using parent-child relationships + const thisPatternObj = routePattern; + const otherPatternObj = otherRoutePrivateProperties.routePattern; - // Check if this matching route is in the same family using parent-child relationships - const thisPatternObj = routePattern; - const otherPatternObj = otherRoutePrivateProperties.routePattern; - - // Routes are in same family if they share a hierarchical relationship: - // 1. One is parent/ancestor of the other - // 2. They share a common parent/ancestor - let inSameFamily = false; - - // Check if other route is ancestor of this route - let currentParent = thisPatternObj.parent; - while (currentParent) { - if (currentParent === otherPatternObj) { - inSameFamily = true; - break; - } - currentParent = currentParent.parent; - } + // Routes are in same family if they share a hierarchical relationship: + // 1. One is parent/ancestor of the other + // 2. They share a common parent/ancestor + let inSameFamily = false; - // Check if this route is ancestor of other route - if (!inSameFamily) { - currentParent = otherPatternObj.parent; + // Check if other route is ancestor of this route + let currentParent = thisPatternObj.parent; while (currentParent) { - if (currentParent === thisPatternObj) { + if (currentParent === otherPatternObj) { inSameFamily = true; break; } currentParent = currentParent.parent; } - } - // Check if they share a common parent (siblings or cousins) - if (!inSameFamily) { - const thisAncestors = new Set(); - currentParent = thisPatternObj.parent; - while (currentParent) { - thisAncestors.add(currentParent); - currentParent = currentParent.parent; + // Check if this route is ancestor of other route + if (!inSameFamily) { + currentParent = otherPatternObj.parent; + while (currentParent) { + if (currentParent === thisPatternObj) { + inSameFamily = true; + break; + } + currentParent = currentParent.parent; + } } - currentParent = otherPatternObj.parent; - while (currentParent) { - if (thisAncestors.has(currentParent)) { - inSameFamily = true; - break; + // Check if they share a common parent (siblings or cousins) + if (!inSameFamily) { + const thisAncestors = new Set(); + currentParent = thisPatternObj.parent; + while (currentParent) { + thisAncestors.add(currentParent); + currentParent = currentParent.parent; + } + + currentParent = otherPatternObj.parent; + while (currentParent) { + if (thisAncestors.has(currentParent)) { + inSameFamily = true; + break; + } + currentParent = currentParent.parent; } - currentParent = currentParent.parent; } - } - if (inSameFamily) { - matchingRouteInSameFamily = true; + if (inSameFamily) { + matchingRouteInSameFamily = true; + } } - } - // Only reset signal if: - // 1. We're navigating within the same route family (not to completely unrelated routes) - // 2. AND no matching route extracts this parameter from URL - // 3. AND parameter has no default value (making it truly optional) - if (matchingRouteInSameFamily && !parameterExtractedByMatchingRoute) { - const defaultValue = connection.getDefaultValue(); - if (defaultValue === undefined) { - // Parameter is not extracted within same family and has no default - reset it - if (debug) { + // Only reset signal if: + // 1. We're navigating within the same route family (not to completely unrelated routes) + // 2. AND no matching route extracts this parameter from URL + // 3. AND parameter has no default value (making it truly optional) + if ( + matchingRouteInSameFamily && + !parameterExtractedByMatchingRoute + ) { + const defaultValue = connection.getDefaultValue(); + if (defaultValue === undefined) { + // Parameter is not extracted within same family and has no default - reset it + if (debug) { + console.debug( + `[route] Same family navigation, ${paramName} not extracted and has no default: resetting signal`, + ); + } + paramSignal.value = undefined; + } else if (debug) { + // Parameter has a default value - preserve current signal value console.debug( - `[route] Same family navigation, ${paramName} not extracted and has no default: resetting signal`, + `[route] Parameter ${paramName} has default value ${defaultValue}: preserving signal value: ${paramSignal.value}`, ); } - paramSignal.value = undefined; } else if (debug) { - // Parameter has a default value - preserve current signal value - console.debug( - `[route] Parameter ${paramName} has default value ${defaultValue}: preserving signal value: ${paramSignal.value}`, - ); + if (!matchingRouteInSameFamily) { + console.debug( + `[route] Different route family: preserving ${paramName} signal value: ${paramSignal.value}`, + ); + } else { + console.debug( + `[route] Parameter ${paramName} extracted by matching route: preserving signal value: ${paramSignal.value}`, + ); + } } - } else if (debug) { - if (!matchingRouteInSameFamily) { - console.debug( - `[route] Different route family: preserving ${paramName} signal value: ${paramSignal.value}`, - ); - } else { + continue; + } + + // URL -> Signal sync: When route matches, ensure signal matches URL state + // URL is the source of truth for explicit parameters + const value = paramSignal.peek(); + if (urlParamValue === undefined) { + // No URL parameter - reset signal to its current default value + // (handles both static fallback and dynamic default cases) + const defaultValue = connection.getDefaultValue(); + if (connection.isDefaultValue(value)) { + // Signal already has correct default value, no sync needed + continue; + } + if (debug) { console.debug( - `[route] Parameter ${paramName} extracted by matching route: preserving signal value: ${paramSignal.value}`, + `[route] URL->Signal: ${paramName} not in URL, reset signal to default (${defaultValue})`, ); } + paramSignal.value = defaultValue; + continue; } - continue; - } - - // URL -> Signal sync: When route matches, ensure signal matches URL state - // URL is the source of truth for explicit parameters - const value = paramSignal.peek(); - if (urlParamValue === undefined) { - // No URL parameter - reset signal to its current default value - // (handles both static fallback and dynamic default cases) - const defaultValue = connection.getDefaultValue(); - if (connection.isDefaultValue(value)) { - // Signal already has correct default value, no sync needed + if (urlParamValue === value) { + // Values already match, no sync needed continue; } if (debug) { console.debug( - `[route] URL->Signal: ${paramName} not in URL, reset signal to default (${defaultValue})`, + `[route] URL->Signal: ${paramName}=${urlParamValue} in url, sync signal with url`, ); } - paramSignal.value = defaultValue; + paramSignal.value = urlParamValue; continue; } - if (urlParamValue === value) { - // Values already match, no sync needed - continue; + } + }); + // Reset flag after URL -> Signal synchronization is complete + isUpdatingRoutesFromUrl = false; + + Object.assign(returnValue, { matchingRouteSet }); + } + + update_route_actions: { + // must be after paramsSignal.value update to ensure the proxy target is set + // (so after the batch call) + const toLoadSet = new Set(); + const toReloadSet = new Set(); + const abortSignalMap = new Map(); + const routeLoadRequestedMap = new Map(); + const shouldLoadOrReload = (route, shouldLoad) => { + const routeAction = route.action; + const currentAction = routeAction.getCurrentAction(); + if (shouldLoad) { + if ( + navigationType === "replace" || + currentAction.aborted || + currentAction.error + ) { + shouldLoad = false; } - if (debug) { + } + if (shouldLoad) { + toLoadSet.add(currentAction); + } else { + toReloadSet.add(currentAction); + } + routeLoadRequestedMap.set(route, currentAction); + // Create a new abort controller for this action + const actionAbortController = new AbortController(); + actionAbortControllerWeakMap.set(currentAction, actionAbortController); + abortSignalMap.set(currentAction, actionAbortController.signal); + }; + const shouldLoad = (route) => { + shouldLoadOrReload(route, true); + }; + const shouldReload = (route) => { + shouldLoadOrReload(route, false); + }; + const shouldAbort = (route) => { + const routeAction = route.action; + const currentAction = routeAction.getCurrentAction(); + const actionAbortController = + actionAbortControllerWeakMap.get(currentAction); + if (actionAbortController) { + actionAbortController.abort(`route no longer matching`); + actionAbortControllerWeakMap.delete(currentAction); + } + }; + for (const { + route, + routePrivateProperties, + newMatching, + oldMatching, + newParams, + oldParams, + } of routeMatchInfoSet) { + const routeAction = route.action; + if (!routeAction) { + continue; + } + + const becomesMatching = newMatching && !oldMatching; + const becomesNotMatching = !newMatching && oldMatching; + const paramsChangedWhileMatching = + newMatching && oldMatching && newParams !== oldParams; + + // Handle actions for routes that become matching + if (becomesMatching) { + if (DEBUG) { console.debug( - `[route] URL->Signal: ${paramName}=${urlParamValue} in url, sync signal with url`, + `${routePrivateProperties} became matching with params:`, + newParams, ); } - paramSignal.value = urlParamValue; + shouldLoad(route); continue; } - } - }); - // Reset flag after URL -> Signal synchronization is complete - isUpdatingRoutesFromUrl = false; - - // must be after paramsSignal.value update to ensure the proxy target is set - // (so after the batch call) - const toLoadSet = new Set(); - const toReloadSet = new Set(); - const abortSignalMap = new Map(); - const routeLoadRequestedMap = new Map(); - - const shouldLoadOrReload = (route, shouldLoad) => { - const routeAction = route.action; - const currentAction = routeAction.getCurrentAction(); - if (shouldLoad) { - if ( - navigationType === "replace" || - currentAction.aborted || - currentAction.error - ) { - shouldLoad = false; - } - } - if (shouldLoad) { - toLoadSet.add(currentAction); - } else { - toReloadSet.add(currentAction); - } - routeLoadRequestedMap.set(route, currentAction); - // Create a new abort controller for this action - const actionAbortController = new AbortController(); - actionAbortControllerWeakMap.set(currentAction, actionAbortController); - abortSignalMap.set(currentAction, actionAbortController.signal); - }; - - const shouldLoad = (route) => { - shouldLoadOrReload(route, true); - }; - const shouldReload = (route) => { - shouldLoadOrReload(route, false); - }; - const shouldAbort = (route) => { - const routeAction = route.action; - const currentAction = routeAction.getCurrentAction(); - const actionAbortController = - actionAbortControllerWeakMap.get(currentAction); - if (actionAbortController) { - actionAbortController.abort(`route no longer matching`); - actionAbortControllerWeakMap.delete(currentAction); - } - }; - - for (const { - route, - routePrivateProperties, - newMatching, - oldMatching, - newParams, - oldParams, - } of routeMatchInfoSet) { - const routeAction = route.action; - if (!routeAction) { - continue; - } - - const becomesMatching = newMatching && !oldMatching; - const becomesNotMatching = !newMatching && oldMatching; - const paramsChangedWhileMatching = - newMatching && oldMatching && newParams !== oldParams; - - // Handle actions for routes that become matching - if (becomesMatching) { - if (DEBUG) { - console.debug( - `${routePrivateProperties} became matching with params:`, - newParams, - ); + // Handle actions for routes that become not matching - abort them + if (becomesNotMatching && ROUTE_DEACTIVATION_STRATEGY === "abort") { + shouldAbort(route); + continue; } - shouldLoad(route); - continue; - } - - // Handle actions for routes that become not matching - abort them - if (becomesNotMatching && ROUTE_DEACTIVATION_STRATEGY === "abort") { - shouldAbort(route); - continue; - } - // Handle parameter changes while route stays matching - if (paramsChangedWhileMatching) { - if (DEBUG) { - console.debug(`${routePrivateProperties} params changed:`, newParams); + // Handle parameter changes while route stays matching + if (paramsChangedWhileMatching) { + if (DEBUG) { + console.debug(`${routePrivateProperties} params changed:`, newParams); + } + shouldReload(route); } - shouldReload(route); } + Object.assign(returnValue, { + loadSet: toLoadSet, + reloadSet: toReloadSet, + abortSignalMap, + routeLoadRequestedMap, + }); } - return { - loadSet: toLoadSet, - reloadSet: toReloadSet, - abortSignalMap, - routeLoadRequestedMap, - matchingRouteSet, - }; + return returnValue; }; const registerRoute = (routePattern) => { @@ -500,10 +519,8 @@ const registerRoute = (routePattern) => { matching: false, params: ROUTE_NOT_MATCHING_PARAMS, buildUrl: null, - bindAction: null, relativeUrl: null, url: null, - action: null, matchingSignal: null, paramsSignal: null, urlSignal: null, @@ -512,6 +529,10 @@ const registerRoute = (routePattern) => { toString: () => { return `route "${cleanPattern}"`; }, + + bindAction: null, + action: null, + actionStatusSignal: null, }; routeSet.add(route); const routePrivateProperties = { @@ -800,7 +821,14 @@ const registerRoute = (routePattern) => { cleanupCallbackSet.add(disposeRelativeUrlEffect); cleanupCallbackSet.add(disposeUrlEffect); - // action stuff (for later) + // action + route.actionStatusSignal = signal({ + loading: false, + error: null, + aborted: false, + completed: true, + data: undefined, + }); route.bindAction = (action) => { const { store } = action.meta; if (store) { @@ -830,6 +858,10 @@ const registerRoute = (routePattern) => { const actionBoundToThisRoute = action.bindParams(route.paramsSignal); route.action = actionBoundToThisRoute; + route.actionStatusSignal = computed(() => { + const actionStatus = getActionStatus(actionBoundToThisRoute); + return actionStatus; + }); return actionBoundToThisRoute; }; diff --git a/packages/frontend/navi/src/nav/route.jsx b/packages/frontend/navi/src/nav/route.jsx index de157f9f19..a24ab96bee 100644 --- a/packages/frontend/navi/src/nav/route.jsx +++ b/packages/frontend/navi/src/nav/route.jsx @@ -6,7 +6,8 @@ * . Tester le code splitting avec .lazy + import dynamique * pour les elements des routes * - * 3. Ajouter la possibilite d'avoir des action sur les routes + * 3. Ajouter la possibilite d'avoir des + * sur les routes * Tester juste les data pour commencer * On aura ptet besoin d'un useRouteData au lieu de passer par un element qui est une fonction * pour que react ne re-render pas tout diff --git a/packages/frontend/navi/src/nav/route_pattern.js b/packages/frontend/navi/src/nav/route_pattern.js index 29a55a08e3..ea3d78bd47 100644 --- a/packages/frontend/navi/src/nav/route_pattern.js +++ b/packages/frontend/navi/src/nav/route_pattern.js @@ -2580,6 +2580,12 @@ export const setupPatterns = (patternDefinitions) => { // Phase 1: Create all pattern objects for (const [key, urlPatternRaw] of Object.entries(patternDefinitions)) { + if (typeof urlPatternRaw !== "string") { + throw new TypeError( + `expects a route pattern string, but received ${urlPatternRaw} for route "${key}".`, + ); + } + // Create the unified pattern object const pattern = createRoutePattern(urlPatternRaw); diff --git a/packages/related/plugin-database-manager/src/client/database/database_routes.jsx b/packages/related/plugin-database-manager/src/client/database/database_routes.jsx index 595348d3af..ebb935887b 100644 --- a/packages/related/plugin-database-manager/src/client/database/database_routes.jsx +++ b/packages/related/plugin-database-manager/src/client/database/database_routes.jsx @@ -4,8 +4,9 @@ import { DatabasePage } from "./database_page.jsx"; export const DatabaseRoutes = () => { return ( - - {(database) => } - + } + /> ); }; diff --git a/packages/related/plugin-database-manager/src/client/explorer/explorer.jsx b/packages/related/plugin-database-manager/src/client/explorer/explorer.jsx index 9d38edd013..8676ec1965 100644 --- a/packages/related/plugin-database-manager/src/client/explorer/explorer.jsx +++ b/packages/related/plugin-database-manager/src/client/explorer/explorer.jsx @@ -1,6 +1,7 @@ import { initFlexDetailsSet } from "@jsenv/dom"; -import { useRunOnMount } from "@jsenv/navi"; +import { Icon, useRunOnMount } from "@jsenv/navi"; import { useLayoutEffect, useRef, useState } from "preact/hooks"; + // import { DatabaseSvg } from "../database/database_icons.jsx"; // import { useCurrentDatabase } from "../database/database_signals.js"; import { @@ -29,8 +30,6 @@ import "./explorer.css" with { type: "css" }; import "./explorer_store.js"; import { EXPLORER } from "./explorer_store.js"; -const FontSizedSvg = (props) => props; - export const Explorer = () => { useRunOnMount(EXPLORER.GET, Explorer); @@ -41,9 +40,9 @@ export const Explorer = () => { return (