Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -456,5 +456,20 @@
['name' => 'admin_demo_showcases#destroy',
'url' => '/api/admin/demo-showcases/{id}', 'verb' => 'DELETE',
'requirements' => ['id' => '[a-z0-9\-]+']],

// Resolve a dashboard's canonical slug-chain path (used by the
// frontend for outbound URL sync after a sidebar switch).
// Registered BEFORE the catch-all deep-link route so the literal
// `/api/dashboards/{uuid}/path` segment is matched first.
['name' => 'dashboard_api#computePath', 'url' => '/api/dashboards/{uuid}/path', 'verb' => 'GET',
'requirements' => ['uuid' => '[A-Za-z0-9\-]+']],

// Deep-link slug-chain → dashboard. MUST be the last route in the
// table so every literal `/api/...` and explicit page route is
// matched first. The negative-lookahead requirement keeps this
// catch-all from swallowing API requests if a future API route is
// inadvertently added below.
['name' => 'page#deepLink', 'url' => '/{deepLink}', 'verb' => 'GET',
'requirements' => ['deepLink' => '(?!api(?:/|$)).+']],
],
];
42 changes: 42 additions & 0 deletions lib/Controller/DashboardApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -602,6 +602,48 @@ public function byPath(string $path=''): JSONResponse
);
}//end byPath()

/**
* GET /api/dashboards/{uuid}/path — return a dashboard's canonical
* slug-chain path.
*
* Used by the frontend after every sidebar switch to keep the
* browser URL in sync with the active dashboard. The path is the
* leading-slash slug-chain returned by
* {@see DashboardTreeService::computePath()}; an empty string means
* the UUID does not resolve OR the dashboard has no slug (legal —
* NULL slugs are simply unaddressable by path), and the frontend
* treats either case as "leave the URL alone".
*
* @param string $uuid Dashboard UUID captured from the URL.
*
* @return JSONResponse `{path: string}` envelope (always 200 when
* authorised — the empty-path case is a valid
* response shape the caller distinguishes
* client-side).
*/
#[NoAdminRequired]
public function computePath(string $uuid=''): JSONResponse
{
if ($this->userId === null) {
return ResponseHelper::unauthorized();
}

if ($uuid === '') {
return new JSONResponse(
data: [
'status' => 'error',
'error' => 'missing_uuid',
'message' => 'UUID is required',
],
statusCode: Http::STATUS_BAD_REQUEST
);
}

return ResponseHelper::success(
data: ['path' => $this->treeService->computePath(uuid: $uuid)]
);
}//end computePath()

/**
* Activate a dashboard.
*
Expand Down
112 changes: 105 additions & 7 deletions lib/Controller/PageController.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
use OCA\MyDash\Db\Dashboard;
use OCA\MyDash\Service\AdminTemplateService;
use OCA\MyDash\Service\DashboardService;
use OCA\MyDash\Service\DashboardTreeService;
use OCA\MyDash\Service\InitialState\Page;
use OCA\MyDash\Service\InitialStateBuilder;
use OCA\MyDash\Service\RoleFeaturePermissionService;
Expand All @@ -42,6 +43,8 @@
use OCP\IRequest;
use OCP\IUserSession;
use OCP\Util;
use Psr\Log\LoggerInterface;
use Throwable;

/**
* Workspace page controller — wires the typed initial-state contract.
Expand Down Expand Up @@ -71,6 +74,15 @@ class PageController extends Controller
* @param RoleFeaturePermissionService $roleFeaturePerm Per-user widget
* allow-list source
* (REQ-RFP-009..010).
* @param DashboardTreeService $treeService Slug-chain
* resolver used by the
* deep-link route.
* @param LoggerInterface $logger Used to record
* silent fallback
* when a deep-link
* path doesn't
* resolve to a
* visible dashboard.
*/
public function __construct(
IRequest $request,
Expand All @@ -81,10 +93,32 @@ public function __construct(
private readonly DashboardService $dashboardService,
private readonly AdminTemplateService $adminTemplateService,
private readonly RoleFeaturePermissionService $roleFeaturePerm,
private readonly DashboardTreeService $treeService,
private readonly LoggerInterface $logger,
) {
parent::__construct(appName: Application::APP_ID, request: $request);
}//end __construct()

/**
* Deep-link entry point — `/apps/mydash/{deepLink}`.
*
* Symfony binds the captured slug-chain into `$deepLink`. Delegating
* to {@see self::index()} keeps the workspace render path single-
* sourced; the optional path argument merely overrides the active
* dashboard before initial-state assembly.
*
* @param string $deepLink Slug-chain captured from the URL (may
* contain `/` separators).
*
* @return TemplateResponse The workspace template response.
*/
#[NoAdminRequired]
#[NoCSRFRequired]
public function deepLink(string $deepLink=''): TemplateResponse
{
return $this->index(deepLink: $deepLink);
}//end deepLink()

/**
* Render the workspace page.
*
Expand All @@ -94,11 +128,22 @@ public function __construct(
* {@see \OCA\MyDash\Exception\MissingInitialStateException} so the page
* never renders with a partial payload.
*
* Deep-link path: when `$deepLink` resolves through the tree service
* to a dashboard the user can read, that dashboard is used as the
* active one (overriding the resolver's seven-step fallback). When
* the path doesn't resolve (renamed, deleted, never existed, or not
* visible to the caller), the controller logs a warning and falls
* back silently — bookmarks of stale slug chains still land on
* something instead of 404'ing.
*
* @param string $deepLink Optional slug-chain selecting the active
* dashboard. Empty string ⇒ default resolver.
*
* @return TemplateResponse The template response.
*/
#[NoAdminRequired]
#[NoCSRFRequired]
public function index(): TemplateResponse
public function index(string $deepLink=''): TemplateResponse
{
Util::addScript(application: Application::APP_ID, file: 'mydash-main');
Util::addStyle(application: Application::APP_ID, file: 'mydash');
Expand Down Expand Up @@ -165,15 +210,50 @@ public function index(): TemplateResponse

$active = null;
if ($userId !== '') {
$active = $this->dashboardService->resolveActiveDashboard(
userId: $userId,
primaryGroupId: $primaryGroupId
);
}
// Deep-link override: when the URL carries a slug-chain we
// try to land the user on that dashboard before consulting
// the seven-step resolver. Failures (path doesn't resolve,
// not visible, throws) are swallowed so a stale bookmark
// still opens *something* instead of breaking.
if ($deepLink !== '') {
try {
$resolved = $this->treeService->resolvePath(path: $deepLink);
if ($resolved !== null) {
$active = $this->dashboardService->getDashboardForUser(
dashboardId: $resolved->getId(),
userId: $userId
);
}
} catch (Throwable $t) {
$this->logger->warning(
message: 'mydash: deep-link resolution failed for path "{path}": {message}',
context: [
'path' => $deepLink,
'message' => $t->getMessage(),
]
);
}

if ($active === null) {
$this->logger->info(
message: 'mydash: deep-link path "{path}" not visible — falling back to default resolver',
context: ['path' => $deepLink]
);
}
}//end if

if ($active === null) {
$active = $this->dashboardService->resolveActiveDashboard(
userId: $userId,
primaryGroupId: $primaryGroupId
);
}
}//end if

$activeDashboardId = '';
$dashboardSource = Dashboard::SOURCE_GROUP;
$layout = [];
$deepLinkPath = '';
if ($active !== null) {
$activeDashboard = $active['dashboard'];
$activeDashboardId = (string) $activeDashboard->getUuid();
Expand All @@ -187,7 +267,24 @@ public function index(): TemplateResponse
},
array: $placements
);
}
// Canonical slug-chain for whatever dashboard ended up active —
// the frontend reads this to keep the URL in sync (e.g. after
// a parent rename, a stale bookmarked path is normalised
// in-place via `history.replaceState`).
try {
$deepLinkPath = $this->treeService->computePath(
uuid: (string) $activeDashboard->getUuid()
);
} catch (Throwable $t) {
$this->logger->warning(
message: 'mydash: failed to compute path for active dashboard {uuid}: {message}',
context: [
'uuid' => (string) $activeDashboard->getUuid(),
'message' => $t->getMessage(),
]
);
}
}//end if

$allowUserDashboards = $this->dashboardService->getAllowUserDashboards();

Expand Down Expand Up @@ -219,6 +316,7 @@ public function index(): TemplateResponse

$builder
->setAllowedWidgets($allowedWidgets)
->setDeepLinkPath($deepLinkPath)
->apply();

// REQ-SHELL-001: pass the chrome slot ids so Nextcloud treats
Expand Down
20 changes: 20 additions & 0 deletions lib/Service/InitialStateBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,26 @@ public function setAllowedWidgets(?array $allowedWidgets): self
return $this;
}//end setAllowedWidgets()

/**
* Set the canonical slug-chain path for the active dashboard
* (workspace).
*
* Read by the frontend on mount to bring `window.location.pathname`
* in line with whichever dashboard was actually rendered — handles
* the renamed-parent / stale-bookmark cases by replacing the URL
* in-place via `history.replaceState`. Empty string when no
* dashboard is active (the page renders the empty state).
*
* @param string $deepLinkPath Canonical path or '' when none.
*
* @return self
*/
public function setDeepLinkPath(string $deepLinkPath): self
{
$this->values['deepLinkPath'] = $deepLinkPath;
return $this;
}//end setDeepLinkPath()

/**
* Set every Nextcloud group (admin).
*
Expand Down
9 changes: 9 additions & 0 deletions src/services/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,15 @@ export const api = {
return axios.get(`${baseUrl}/api/dashboards/by-path/${cleanPath}`)
},

// Reverse lookup: dashboard UUID → canonical slug-chain path. Used
// by the sidebar after every switch to keep `window.location.pathname`
// in sync with the active dashboard. Server returns `{path: '...'}`;
// an empty path is a valid response (the dashboard has no slug, so
// there is no addressable URL).
getDashboardPath(uuid) {
return axios.get(`${baseUrl}/api/dashboards/${encodeURIComponent(uuid)}/path`)
},

// REQ-LOCK-001..008: dashboard editing-lock management.
// Re-entrant for the same user (a second tab refreshes the lease
// instead of getting 409). Heartbeat MUST be sent every 60 s by
Expand Down
8 changes: 8 additions & 0 deletions src/utils/loadInitialState.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,14 @@ const PAGE_KEYS = {
// REQ-RFP-010: list of widget IDs the caller is permitted to see.
// `null` = no role-feature-permissions configured (legacy, unrestricted).
allowedWidgets: null,
// Canonical slug-chain path for the active dashboard. Empty when
// no dashboard is active or the active one has no slug. The
// frontend uses this on mount to bring `window.location.pathname`
// in line with whichever dashboard the server actually rendered
// (handles renamed parents / stale bookmarks via replaceState).
// Optional key — older servers don't push it, so the default
// keeps reads typed even before the deploy lands.
deepLinkPath: '',
},
admin: {
allGroups: [],
Expand Down
Loading
Loading