Skip to content
Open
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
2 changes: 1 addition & 1 deletion server/boards/boardsapp_util.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ func createBoardsConfig(mmconfig mm_model.Config, baseURL string, serverID strin
showFullName = *mmconfig.PrivacySettings.ShowFullName
}

serverRoot := baseURL + "/plugins/focalboard"
serverRoot := baseURL + "/boards"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we sure this works? Wondering how this used to work before this change. And we should also verify this change in desktop app as IIRC, there is some boards and playbook specific routing code in the desktop app that is dependent on the URL.


return &config.Configuration{
ServerRoot: serverRoot,
Expand Down
5 changes: 5 additions & 0 deletions webapp/src/components/sidebar/sidebarCategory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {TOUR_SIDEBAR, SidebarTourSteps, TOUR_BOARD, FINISHED} from '../../compon
import telemetryClient, {TelemetryActions, TelemetryCategory} from '../../telemetry/telemetryClient'

import {getCurrentTeam} from '../../store/teams'
import {UserSettings} from '../../userSettings'

import ConfirmationDialogBox, {ConfirmationDialogBoxProps} from '../confirmationDialogBox'

Expand Down Expand Up @@ -183,6 +184,10 @@ const SidebarCategory = (props: Props) => {
// Capture the category ID before deletion
const deletedFromCategoryID = props.categoryBoards.id

// Clear localStorage entries for the deleted board
UserSettings.setLastBoardID(teamID, null)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

More of the clean up: delete boardId and viewId from local storage when board is deleted

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should only do this if the boards being deleted is the current board. Currently if you are on Board A and Delete Board B, this clears the last board ID while in fact the last last board ID should continue pointing to Board A.

Additionally, I think this is causing one more issue where if you have Board A, B, C and D in that order, and you are on D and delete B, you end up navigating to C, but you should continue to stay on D

Screen.Recording.2025-10-17.at.8.52.17.AM.mov

UserSettings.setLastViewId(deleteBoard.id, null)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same applied to this - only delete if deleting the current view.


mutator.deleteBoard(
deleteBoard,
intl.formatMessage({id: 'Sidebar.delete-board', defaultMessage: 'Delete board'}),
Expand Down
2 changes: 1 addition & 1 deletion webapp/src/error_boundary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export default class ErrorBoundary extends React.Component<Props, State> {
msg = 'Redirecting to error page...'

handleError = (): void => {
const url = Utils.getBaseURL() + '/error?id=unknown'
const url = Utils.getFrontendBaseURL(true) + '/error?id=unknown'
Utils.log('error boundary redirecting to ' + url)
window.location.replace(url)
}
Expand Down
9 changes: 9 additions & 0 deletions webapp/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ enum ErrorId {
NotLoggedIn = 'not-logged-in',
InvalidReadOnlyBoard = 'invalid-read-only-board',
BoardNotFound = 'board-not-found',
ViewNotFound = 'view-not-found',
}

type ErrorDef = {
Expand Down Expand Up @@ -66,6 +67,14 @@ function errorDefFromId(id: ErrorId | null): ErrorDef {
errDef.button1Fill = true
break
}
case ErrorId.ViewNotFound: {
errDef.title = intl.formatMessage({id: 'error.view-not-found', defaultMessage: 'View not found.'})
errDef.button1Enabled = true
errDef.button1Text = intl.formatMessage({id: 'error.back-to-board', defaultMessage: 'Back to board'})
errDef.button1Redirect = '/'
errDef.button1Fill = true
break
}
Comment on lines +70 to +77
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Inconsistent redirect path for "Back to board" button.

The button text says "Back to board" but button1Redirect is set to '/', which redirects to the root/home. For consistency with the user expectation, this should redirect back to the board (without the invalid viewId) rather than the root.

Consider using a function redirect similar to other error handlers to extract the boardId from URL params:

💡 Suggested approach
 case ErrorId.ViewNotFound: {
     errDef.title = intl.formatMessage({id: 'error.view-not-found', defaultMessage: 'View not found.'})
     errDef.button1Enabled = true
     errDef.button1Text = intl.formatMessage({id: 'error.back-to-board', defaultMessage: 'Back to board'})
-    errDef.button1Redirect = '/'
+    errDef.button1Redirect = (params: URLSearchParams): string => {
+        const boardId = params.get('boardId')
+        const teamId = params.get('teamId')
+        if (teamId && boardId) {
+            return `/team/${teamId}/${boardId}`
+        }
+        return '/'
+    }
     errDef.button1Fill = true
     break
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@webapp/src/errors.ts` around lines 70 - 77, The "Back to board" button in the
ErrorId.ViewNotFound block currently sets button1Redirect = '/' which sends
users to root; change it to compute the board redirect like the other error
handlers by extracting boardId from URL params and setting button1Redirect to
the board path (e.g., `/b/${boardId}`) instead of '/'; update the
ErrorId.ViewNotFound branch where errDef.button1Redirect is assigned so it uses
the same helper/function used elsewhere to build the board redirect (keep
errDef.title, button1Enabled, button1Text, and button1Fill as-is).

case ErrorId.NotLoggedIn: {
errDef.title = intl.formatMessage({id: 'error.not-logged-in', defaultMessage: 'Your session may have expired or you\'re not logged in. Log in again to access Boards.'})
errDef.button1Enabled = true
Expand Down
106 changes: 84 additions & 22 deletions webapp/src/pages/boardPage/boardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,13 @@ import {
setCurrent as setCurrentBoard,
fetchBoardMembers,
addMyBoardMemberships,
getBoards,
} from '../../store/boards'
import {getCurrentViewId, setCurrent as setCurrentView, updateViews} from '../../store/views'
import {getCurrentViewId, getCurrentBoardViews, setCurrent as setCurrentView, updateViews} from '../../store/views'
import ConfirmationDialog from '../../components/confirmationDialogBox'
import {initialLoad, initialReadOnlyLoad, loadBoardData} from '../../store/initialLoad'
import {useAppSelector, useAppDispatch} from '../../store/hooks'
import {setTeam} from '../../store/teams'
import {getAllTeams, setTeam} from '../../store/teams'
import {updateCards} from '../../store/cards'
import {updateComments} from '../../store/comments'
import {updateAttachments} from '../../store/attachments'
Expand Down Expand Up @@ -82,6 +83,10 @@ const BoardPage = (props: Props): JSX.Element => {
const category = useAppSelector(getCategoryOfBoard(activeBoardId))
const [showJoinBoardDialog, setShowJoinBoardDialog] = useState<boolean>(false)
const history = useHistory()
const allTeams = useAppSelector(getAllTeams)
const allBoards = useAppSelector(getBoards)
const boardViews = useAppSelector(getCurrentBoardViews)
const [loadedBoardId, setLoadedBoardId] = useState<string>('')

// if we're in a legacy route and not showing a shared board,
// redirect to the new URL schema equivalent
Expand All @@ -102,10 +107,18 @@ const BoardPage = (props: Props): JSX.Element => {

// TODO: Make this less brittle. This only works because this is the root render function
useEffect(() => {
UserSettings.lastTeamId = teamId
octoClient.teamId = teamId
dispatch(setTeam(teamId))
}, [teamId])
if (allTeams.length === 0) {
return
}
const isValidTeam = allTeams.some((team) => team.id === match.params.teamId) || match.params.teamId === Constants.globalTeamId
if (isValidTeam) {
UserSettings.lastTeamId = teamId
octoClient.teamId = teamId
dispatch(setTeam(teamId))
} else {
dispatch(setGlobalError('team-undefined'))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure what this does, but for example if I open the url http://localhost:8065/boards/team/invalid-team-id on this branch, there are no errors and an empty template selector screen-

Image

Whereas if I try to do the same thing on Hub, I get the screen with right error message-

Image

Comment on lines +113 to +119
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Validate against resolved teamId, not only match.params.teamId.

Line 113 can mark valid fallback routes as invalid (and set team-undefined) when teamId comes from UserSettings.lastTeamId or Constants.globalTeamId.

🔧 Suggested fix
-        const isValidTeam = allTeams.some((team) => team.id === match.params.teamId) || match.params.teamId === Constants.globalTeamId
+        const isValidTeam = (
+            teamId === Constants.globalTeamId ||
+            allTeams.some((team) => team.id === teamId)
+        )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const isValidTeam = allTeams.some((team) => team.id === match.params.teamId) || match.params.teamId === Constants.globalTeamId
if (isValidTeam) {
UserSettings.lastTeamId = teamId
octoClient.teamId = teamId
dispatch(setTeam(teamId))
} else {
dispatch(setGlobalError('team-undefined'))
const isValidTeam = (
teamId === Constants.globalTeamId ||
allTeams.some((team) => team.id === teamId)
)
if (isValidTeam) {
UserSettings.lastTeamId = teamId
octoClient.teamId = teamId
dispatch(setTeam(teamId))
} else {
dispatch(setGlobalError('team-undefined'))
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@webapp/src/pages/boardPage/boardPage.tsx` around lines 113 - 119, The
validity check is using match.params.teamId instead of the already-resolved
teamId, causing valid fallback routes to be treated as invalid; update the
condition in the block that computes isValidTeam to compare against the resolved
teamId variable (and Constants.globalTeamId) and then proceed to set
UserSettings.lastTeamId, octoClient.teamId and dispatch(setTeam(teamId)) when
that resolved teamId is valid, otherwise dispatch the error as before; look for
references to match.params.teamId and teamId in this component (e.g.,
isValidTeam, UserSettings.lastTeamId, octoClient.teamId, dispatch(setTeam)) and
replace the former with the resolved teamId in the validation.

}
}, [teamId, allTeams])

const loadAction: (boardId: string) => any = useMemo(() => {
if (props.readonly) {
Expand Down Expand Up @@ -194,6 +207,21 @@ const BoardPage = (props: Props): JSX.Element => {
}

const joinBoard = async (myUser: IUser, boardTeamId: string, boardId: string, allowAdmin: boolean) => {
const boardExists = await octoClient.getBoard(boardId)
if (!boardExists) {
UserSettings.setLastBoardID(boardTeamId, null)
UserSettings.setLastViewId(boardId, null)
dispatch(setGlobalError('board-not-found'))
return
}

if (boardExists.teamId !== boardTeamId && boardExists.teamId !== Constants.globalTeamId) {
UserSettings.setLastBoardID(boardTeamId, null)
UserSettings.setLastViewId(boardId, null)
dispatch(setGlobalError('board-not-found'))
return
}

const member = await octoClient.joinBoard(boardId, allowAdmin)
if (!member) {
if (myUser.permissions?.find((s) => s === 'manage_system' || s === 'manage_team')) {
Expand Down Expand Up @@ -229,28 +257,62 @@ const BoardPage = (props: Props): JSX.Element => {
}, [])

useEffect(() => {
dispatch(loadAction(match.params.boardId))
if (!match.params.boardId) {
return
}
dispatch(setCurrentBoard(match.params.boardId))

if (match.params.boardId) {
// set the active board
dispatch(setCurrentBoard(match.params.boardId))

if (viewId !== Constants.globalTeamId) {
// reset current, even if empty string
dispatch(setCurrentView(viewId))
if (viewId) {
// don't reset per board if empty string
UserSettings.setLastViewId(match.params.boardId, viewId)
}
if (viewId !== Constants.globalTeamId) {
dispatch(setCurrentView(viewId))
if (viewId) {
UserSettings.setLastViewId(match.params.boardId, viewId)
}
}
Comment on lines +265 to 270
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find Constants.globalTeamId definition
rg "Constants\s*=" --type ts --type tsx -A 20 | head -100
echo "---"
# Find globalTeamId definition
rg "globalTeamId" --type ts --type tsx -B 2 -A 2

Repository: mattermost/mattermost-plugin-boards

Length of output: 155


🏁 Script executed:

#!/bin/bash
# Read the specific file around lines 265-270 to see the context
cat -n webapp/src/pages/boardPage/boardPage.tsx | sed -n '250,280p'

Repository: mattermost/mattermost-plugin-boards

Length of output: 1067


🏁 Script executed:

#!/bin/bash
# Find Constants file
fd -e ts -e tsx "Constants" --type f

Repository: mattermost/mattermost-plugin-boards

Length of output: 61


🏁 Script executed:

#!/bin/bash
# Search for Constants definition more broadly
rg "globalTeamId" -B 3 -A 3 --max-count 20

Repository: mattermost/mattermost-plugin-boards

Length of output: 11874


🏁 Script executed:

#!/bin/bash
# Find Constants file
find . -name "Constants*" -o -name "constants*" | grep -E "\.(ts|js)$"

Repository: mattermost/mattermost-plugin-boards

Length of output: 104


🏁 Script executed:

#!/bin/bash
# Look at the imports in boardPage.tsx to find where Constants comes from
head -50 webapp/src/pages/boardPage/boardPage.tsx | grep -E "import.*Constants"

Repository: mattermost/mattermost-plugin-boards

Length of output: 61


🏁 Script executed:

#!/bin/bash
# Search for Constants export or definition
rg "export.*Constants|const Constants" --max-count 20

Repository: mattermost/mattermost-plugin-boards

Length of output: 133


🏁 Script executed:

#!/bin/bash
# Check if globalViewId constant exists
rg "globalViewId" --max-count 10

Repository: mattermost/mattermost-plugin-boards

Length of output: 61


🏁 Script executed:

#!/bin/bash
# Check the constants file to see what constants are defined
cat -n webapp/src/constants.ts | head -60

Repository: mattermost/mattermost-plugin-boards

Length of output: 2540


🏁 Script executed:

#!/bin/bash
# Continue reading constants file to find globalTeamId and related constants
cat -n webapp/src/constants.ts | grep -A 5 -B 5 "globalTeamId"

Repository: mattermost/mattermost-plugin-boards

Length of output: 353


🏁 Script executed:

#!/bin/bash
# Check if there's a globalViewId or similar constant, or search for '0' usage in views
rg "setCurrentView|activeViewId|currentViewId" --max-count 15 -B 2 -A 2

Repository: mattermost/mattermost-plugin-boards

Length of output: 7051


🏁 Script executed:

#!/bin/bash
# Check if there are any view-related default constants
rg "readonly.*=.*'0'" webapp/src/constants.ts -B 2 -A 2

Repository: mattermost/mattermost-plugin-boards

Length of output: 207


🏁 Script executed:

#!/bin/bash
# Check what viewId types are in the store/views
rg "interface.*View|type.*View" webapp/src/store/views.ts -A 10 | head -50

Repository: mattermost/mattermost-plugin-boards

Length of output: 355


🏁 Script executed:

#!/bin/bash
# Look at the full context of the effect in boardPage.tsx around line 265
cat -n webapp/src/pages/boardPage/boardPage.tsx | sed -n '259,280p'

Repository: mattermost/mattermost-plugin-boards

Length of output: 816


🏁 Script executed:

#!/bin/bash
# Check if viewId being undefined is expected and how it should be handled
rg "match.params.viewId" webapp/src/pages/boardPage/ -B 3 -A 3

Repository: mattermost/mattermost-plugin-boards

Length of output: 4929


🏁 Script executed:

#!/bin/bash
# Check how '0' is used in view contexts throughout the codebase
rg "viewId.*===.*'0'|'0'.*viewId" -B 3 -A 3

Repository: mattermost/mattermost-plugin-boards

Length of output: 864


🏁 Script executed:

#!/antml:thinking>
Actually, I think I have enough information now. Let me analyze:

1. Constants.globalTeamId = '0'
2. viewId comes from match.params.viewId (a URL parameter)
3. The check `if (viewId !== Constants.globalTeamId)` means `if (viewId !== '0')`
4. In backward compatibility code, '0' was explicitly used as a viewId placeholder
5. There is NO Constants.globalViewId - the codebase uses '0' as the default for both teams and views

So the original review comment is PARTIALLY correct:
- The comparison IS semantically confusing (using a "Team" constant for views)
- BUT it's functionally correct - '0' IS intentionally used as a default/global view ID
- The concern about it being a "no-op guard" is incorrect - it does filter out '0' viewIds

The real issue is:
1. Code clarity: using Constants.globalTeamId for view validation is confusing
2. Should have Constants.globalViewId OR use a magic string '0' with a comment
3. The logic itself is correct

This is a MINOR issue - more about code clarity/maintainability than a functional bug.
</thinking>

Repository: mattermost/mattermost-plugin-boards

Length of output: 313


🏁 Script executed:

#!/bin/bash
# Check the full view validation effect to understand the complete logic
cat -n webapp/src/pages/boardPage/boardPage.tsx | sed -n '304,320p'

Repository: mattermost/mattermost-plugin-boards

Length of output: 864


Use a dedicated constant for view IDs or add a clarifying comment.

The condition viewId !== Constants.globalTeamId works correctly (comparing against '0'), but is semantically confusing: globalTeamId is a team-scoped constant being used to validate view IDs. While '0' is intentionally used as a default ID across teams, boards, and views, the code would be clearer with either a Constants.globalViewId constant or an explanatory comment (e.g., // Skip setting view if no specific view ID provided).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@webapp/src/pages/boardPage/boardPage.tsx` around lines 265 - 270, Replace the
semantically confusing use of Constants.globalTeamId when checking view IDs:
either add a new constant (e.g., Constants.globalViewId = '0') and use that in
the conditional (replace Constants.globalTeamId with Constants.globalViewId in
the block that calls dispatch(setCurrentView(viewId)) and
UserSettings.setLastViewId(match.params.boardId, viewId)), or if adding a
constant is not desired, add a one-line clarifying comment above the if (viewId
!== Constants.globalTeamId) explaining that '0' is the global/no-specific-view
sentinel and therefore should be skipped; ensure the symbols viewId,
setCurrentView, UserSettings.setLastViewId and match.params.boardId are left
intact.

}, [teamId, match.params.boardId, viewId, me?.id])

dispatch(loadAction(match.params.boardId))
}, [teamId, match.params.boardId, viewId, me?.id, dispatch, loadAction])
Comment on lines 259 to +273
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

loadAction is triggered on view changes due to effect coupling.

Because viewId is in the dependency list and Line 272 is inside the same effect, switching views can re-dispatch loadAction(match.params.boardId), causing unnecessary reload/API churn.

🔧 Suggested refactor
-    useEffect(() => {
-        if (!match.params.boardId) {
-            return
-        }
-        dispatch(setCurrentBoard(match.params.boardId))
-
-        if (viewId !== Constants.globalTeamId) {
-            dispatch(setCurrentView(viewId))
-            if (viewId) {
-                UserSettings.setLastViewId(match.params.boardId, viewId)
-            }
-        }
-
-        dispatch(loadAction(match.params.boardId))
-    }, [teamId, match.params.boardId, viewId, me?.id, dispatch, loadAction])
+    useEffect(() => {
+        if (!match.params.boardId) {
+            return
+        }
+        dispatch(setCurrentBoard(match.params.boardId))
+        dispatch(loadAction(match.params.boardId))
+    }, [match.params.boardId, dispatch, loadAction])
+
+    useEffect(() => {
+        if (!match.params.boardId) {
+            return
+        }
+        if (viewId !== Constants.globalTeamId) {
+            dispatch(setCurrentView(viewId))
+            if (viewId) {
+                UserSettings.setLastViewId(match.params.boardId, viewId)
+            }
+        }
+    }, [match.params.boardId, viewId, dispatch])
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@webapp/src/pages/boardPage/boardPage.tsx` around lines 259 - 273, The effect
in boardPage.tsx currently mixes view-related logic with data loading so that
changes to viewId re-trigger dispatch(loadAction(match.params.boardId)); split
this into two effects: keep the existing useEffect (the one that calls
dispatch(setCurrentBoard(...)), dispatch(setCurrentView(...)), and
UserSettings.setLastViewId(...)) to depend on viewId/teamId/match.params.boardId
as needed, and move dispatch(loadAction(match.params.boardId)) into a separate
effect that only depends on match.params.boardId (and
teamId/me?.id/dispatch/loadAction as required) so switching views no longer
causes loadAction to run. Ensure loadAction is stable (memoized) or included in
the new effect’s dependency array.



useEffect(() => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This creates a new bug. If you have two teams with following boards-

  1. Team A - Board A, B and C
  2. Team B - Board D, E, F

If you open team B in boards and open board E, navigate back to Channels, then back to boards, it should stay you on Board E, but currently it navigates to first board D.

Additionally, if you open Team B Board E, then switch to team A, then to team B, it ends up switching to board D but it should stay on the board which was previously open.

I'm not sure whats the need of resetting board ID on team switch when we store each team's last board ID in local storage.

setLoadedBoardId('')
}, [teamId])

useEffect(() => {
if (!match.params.boardId || !me || props.readonly) {
return
}

if (loadedBoardId === match.params.boardId) {
return
}

const boardsLoaded = Object.keys(allBoards).length > 0
if (!boardsLoaded) {
return
}

const board = allBoards[match.params.boardId]
if (board && board.teamId !== teamId && board.teamId !== Constants.globalTeamId) {
dispatch(setGlobalError('board-not-found'))
return
}

setLoadedBoardId(match.params.boardId)
loadOrJoinBoard(me, teamId, match.params.boardId)
}, [teamId, match.params.boardId, me, props.readonly, allBoards, loadOrJoinBoard, dispatch, loadedBoardId])

// Validate that the viewId exists in the board's views
useEffect(() => {
if (match.params.boardId && !props.readonly && me) {
loadOrJoinBoard(me, teamId, match.params.boardId)
if (!match.params.viewId || props.readonly || props.new || !activeBoardId || boardViews.length === 0) {
return
}

const viewExists = boardViews.some((view) => view.id === match.params.viewId)
if (!viewExists) {
UserSettings.setLastViewId(activeBoardId, null)
dispatch(setGlobalError('view-not-found'))
}
}, [teamId, match.params.boardId, me?.id])
}, [match.params.viewId, activeBoardId, boardViews, props.readonly, props.new])

const handleUnhideBoard = async (boardID: string) => {
if (!me || !category) {
Expand Down
98 changes: 56 additions & 42 deletions webapp/src/pages/boardPage/teamToBoardAndViewRedirect.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Copyright (c) 2020-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.

import {useEffect} from 'react'
import {useEffect, useMemo} from 'react'
import {generatePath, useHistory, useRouteMatch} from 'react-router-dom'

import {getBoards, getCurrentBoardId} from '../../store/boards'
Expand All @@ -22,66 +22,80 @@ const TeamToBoardAndViewRedirect = (): null => {
const boards = useAppSelector(getBoards)
const teamId = match.params.teamId || UserSettings.lastTeamId || Constants.globalTeamId

const boardCount = useMemo(() => Object.keys(boards).length, [boards])
const categoryCount = useMemo(() => categories.length, [categories])

useEffect(() => {
let boardID = match.params.boardId
if (!match.params.boardId) {
// first preference is for last visited board
boardID = UserSettings.lastBoardId[teamId]

// if last visited board is unavailable, use the first board in categories list
if (!boardID && categories.length > 0) {
let goToBoardID: string | null = null

for (const category of categories) {
for (const boardMetadata of category.boardMetadata) {
// pick the first category board that exists and is not hidden
if (!boardMetadata.hidden && boards[boardMetadata.boardID]) {
goToBoardID = boardMetadata.boardID
break
}
if (match.params.boardId) {
return
}

if (boardCount === 0 && categoryCount === 0) {
return
}

let boardID: string | undefined = undefined

const lastBoardId = UserSettings.lastBoardId[teamId]
if (lastBoardId) {
const board = boards[lastBoardId]
if (board && (board.teamId === teamId || board.teamId === Constants.globalTeamId)) {
boardID = lastBoardId
} else {
UserSettings.setLastBoardID(teamId, null)
}
}

if (!boardID && categoryCount > 0) {
for (const category of categories) {
for (const boardMetadata of category.boardMetadata) {
const board = boards[boardMetadata.boardID]
// Pick the first category board that exists, is not hidden, and belongs to this team
if (!boardMetadata.hidden && board && (board.teamId === teamId || board.teamId === Constants.globalTeamId)) {
boardID = boardMetadata.boardID
break
}
}

// there may even be no boards at all
if (goToBoardID) {
boardID = goToBoardID
if (boardID) {
break
}
}
}

if (boardID) {
const newPath = generatePath(Utils.getBoardPagePath(match.path), {...match.params, boardId: boardID, viewID: undefined})
history.replace(newPath)

// return from here because the loadBoardData() call
// will fetch the data to be used below. We'll
// use it in the next render cycle.
return
}
if (boardID) {
const newPath = generatePath(Utils.getBoardPagePath(match.path), {...match.params, boardId: boardID, viewID: undefined})
history.replace(newPath)
return
}

let viewID = match.params.viewId
}, [teamId, match.params.boardId, boardCount, categoryCount, boards, categories, history, match.path, match.params])

const viewCount = useMemo(() => boardViews.length, [boardViews])

useEffect(() => {
const viewID = match.params.viewId

// when a view isn't open,
// but the data is available, try opening a view
if ((!viewID || viewID === '0') && boardId && boardId === match.params.boardId && boardViews && boardViews.length > 0) {
if ((!viewID || viewID === '0') && boardId && boardId === match.params.boardId && viewCount > 0) {
// most recent view gets the first preference
viewID = UserSettings.lastViewId[boardID]
if (viewID) {
UserSettings.setLastViewId(boardID, viewID)
dispatch(setCurrentView(viewID))
let selectedViewID = UserSettings.lastViewId[boardId]
if (selectedViewID) {
UserSettings.setLastViewId(boardId, selectedViewID)
dispatch(setCurrentView(selectedViewID))
} else if (boardViews.length > 0) {
// if most recent view is unavailable, pick the first view
viewID = boardViews[0].id
UserSettings.setLastViewId(boardID, viewID)
dispatch(setCurrentView(viewID))
selectedViewID = boardViews[0].id
UserSettings.setLastViewId(boardId, selectedViewID)
dispatch(setCurrentView(selectedViewID))
Comment on lines +82 to +90
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Validate persisted lastViewId before using it.

If local storage contains a stale view ID, this code dispatches/redirects to a non-existent view instead of falling back to a valid one.

🔧 Suggested fix
-            let selectedViewID = UserSettings.lastViewId[boardId]
-            if (selectedViewID) {
-                UserSettings.setLastViewId(boardId, selectedViewID)
-                dispatch(setCurrentView(selectedViewID))
-            } else if (boardViews.length > 0) {
+            let selectedViewID = UserSettings.lastViewId[boardId]
+            if (selectedViewID && !boardViews.some((view) => view.id === selectedViewID)) {
+                UserSettings.setLastViewId(boardId, null)
+                selectedViewID = undefined
+            }
+
+            if (selectedViewID) {
+                UserSettings.setLastViewId(boardId, selectedViewID)
+                dispatch(setCurrentView(selectedViewID))
+            } else if (boardViews.length > 0) {
                 // if most recent view is unavailable, pick the first view
                 selectedViewID = boardViews[0].id
                 UserSettings.setLastViewId(boardId, selectedViewID)
                 dispatch(setCurrentView(selectedViewID))
             }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let selectedViewID = UserSettings.lastViewId[boardId]
if (selectedViewID) {
UserSettings.setLastViewId(boardId, selectedViewID)
dispatch(setCurrentView(selectedViewID))
} else if (boardViews.length > 0) {
// if most recent view is unavailable, pick the first view
viewID = boardViews[0].id
UserSettings.setLastViewId(boardID, viewID)
dispatch(setCurrentView(viewID))
selectedViewID = boardViews[0].id
UserSettings.setLastViewId(boardId, selectedViewID)
dispatch(setCurrentView(selectedViewID))
let selectedViewID = UserSettings.lastViewId[boardId]
if (selectedViewID && !boardViews.some((view) => view.id === selectedViewID)) {
UserSettings.setLastViewId(boardId, null)
selectedViewID = undefined
}
if (selectedViewID) {
UserSettings.setLastViewId(boardId, selectedViewID)
dispatch(setCurrentView(selectedViewID))
} else if (boardViews.length > 0) {
// if most recent view is unavailable, pick the first view
selectedViewID = boardViews[0].id
UserSettings.setLastViewId(boardId, selectedViewID)
dispatch(setCurrentView(selectedViewID))
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@webapp/src/pages/boardPage/teamToBoardAndViewRedirect.tsx` around lines 82 -
90, The persisted UserSettings.lastViewId[boardId] must be validated against the
current boardViews before using it; update the logic in
teamToBoardAndViewRedirect so that when you read selectedViewID from
UserSettings.lastViewId[boardId] you check that boardViews.some(v => v.id ===
selectedViewID) (or equivalent) before calling UserSettings.setLastViewId and
dispatch(setCurrentView); if the persisted id is missing or not found, fall back
to the first available view (boardViews[0].id), set it via
UserSettings.setLastViewId(boardId, selectedViewID) and
dispatch(setCurrentView(selectedViewID)) instead.

}

if (viewID) {
const newPath = generatePath(Utils.getBoardPagePath(match.path), {...match.params, viewId: viewID})
if (selectedViewID) {
const newPath = generatePath(Utils.getBoardPagePath(match.path), {...match.params, viewId: selectedViewID})
history.replace(newPath)
}
}
}, [teamId, match.params.boardId, match.params.viewId, categories.length, boardViews.length, boardId])
}, [match.params.boardId, match.params.viewId, viewCount, boardId, boardViews, dispatch, history, match.path, match.params])

return null
}
Expand Down
8 changes: 7 additions & 1 deletion webapp/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {IAppWindow} from './types'
import {ChangeHandlerType, WSMessage} from './wsclient'
import {BoardCategoryWebsocketData, Category} from './store/sidebar'
import {UserSettings} from './userSettings'
import {Constants} from './constants'

declare let window: IAppWindow

Expand Down Expand Up @@ -575,7 +576,8 @@ class Utils {
}

static getFrontendBaseURL(absolute?: boolean): string {
let frontendBaseURL = window.frontendBaseURL || Utils.getBaseURL()
// Always use /boards as the frontend base URL, never fall back to baseURL
let frontendBaseURL = window.frontendBaseURL ? window.frontendBaseURL : '/boards'
frontendBaseURL = frontendBaseURL.replace(/\/+$/, '')
if (frontendBaseURL.indexOf('/') === 0) {
frontendBaseURL = frontendBaseURL.slice(1)
Expand Down Expand Up @@ -785,6 +787,10 @@ class Utils {
match: routerMatch<{boardId: string, viewId?: string, cardId?: string, teamId?: string}>,
history: History,
) {
// Set the board as last viewed board in localStorage
const teamId = match.params.teamId || UserSettings.lastTeamId || Constants.globalTeamId
UserSettings.setLastBoardID(teamId, boardId)

// if the same board, reuse the match params
// otherwise remove viewId and cardId, results in first view being selected
const params = {...match.params, boardId: boardId || ''}
Expand Down
Loading