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
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { SettingsMobileNav } from "@/components/settings/mobile";
import { WORKSPACE_SETTINGS_ACCESS } from "@plane/constants";
import type { EUserWorkspaceRoles } from "@plane/types";
// plane web components
import { SettingsRightSidebar } from "@/plane-web/components/settings/right-sidebar";
import { WorkspaceSettingsRightSidebar } from "@/plane-web/components/workspace/right-sidebar";
// hooks
import { useUserPermissions } from "@/hooks/store/user";
// local components
Expand Down Expand Up @@ -48,7 +48,7 @@ const WorkspaceSettingLayout = observer(function WorkspaceSettingLayout({ params
<div className="w-full h-full overflow-y-scroll md:pt-page-y">
<Outlet />
</div>
<SettingsRightSidebar workspaceSlug={workspaceSlug} />
<WorkspaceSettingsRightSidebar workspaceSlug={workspaceSlug} />
</div>
)}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ const WorkspaceMembersSettingsPage = observer(function WorkspaceMembersSettingsP
const {
workspace: { workspaceMemberIds, inviteMembersToWorkspace, filtersStore },
} = useMember();
const { currentWorkspace } = useWorkspace();
const { currentWorkspace, mutateWorkspaceMembersActivity } = useWorkspace();
const { t } = useTranslation();

// derived values
Expand All @@ -53,6 +53,7 @@ const WorkspaceMembersSettingsPage = observer(function WorkspaceMembersSettingsP
const handleWorkspaceInvite = async (data: IWorkspaceBulkInviteFormData) => {
try {
await inviteMembersToWorkspace(workspaceSlug, data);
void mutateWorkspaceMembersActivity(workspaceSlug);

setInviteModal(false);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,10 @@ import { SettingsMobileNav } from "@/components/settings/mobile";
import { ProjectSettingsSidebar } from "@/components/settings/project/sidebar";
// plane web imports
import { ProjectAuthWrapper } from "@/plane-web/layouts/project-wrapper";
import { SettingsRightSidebar } from "@/plane-web/components/settings/right-sidebar";
// types
import type { Route } from "./+types/layout";

export const ProjectDetailSettingsLayout = observer(function ProjectDetailSettingsLayout({
params,
}: Route.ComponentProps) {
function ProjectDetailSettingsLayout({ params }: Route.ComponentProps) {
const { workspaceSlug, projectId } = params;
// router
const pathname = usePathname();
Expand All @@ -27,11 +24,10 @@ export const ProjectDetailSettingsLayout = observer(function ProjectDetailSettin
<div className="w-full h-full overflow-y-scroll md:pt-page-y">
<Outlet />
</div>
<SettingsRightSidebar workspaceSlug={workspaceSlug} projectId={projectId} />
</ProjectAuthWrapper>
</div>
</>
);
});
}

export default ProjectDetailSettingsLayout;
export default observer(ProjectDetailSettingsLayout);

This file was deleted.

10 changes: 0 additions & 10 deletions apps/web/ce/components/settings/right-sidebar.tsx

This file was deleted.

1 change: 1 addition & 0 deletions apps/web/ce/components/workspace/right-sidebar/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./root";
10 changes: 10 additions & 0 deletions apps/web/ce/components/workspace/right-sidebar/root.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { observer } from "mobx-react";

type TWorkspaceSettingsRightSidebarProps = { workspaceSlug: string };

export const WorkspaceSettingsRightSidebar = observer(function WorkspaceSettingsRightSidebar(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
props: TWorkspaceSettingsRightSidebarProps
) {
return <></>;
});
9 changes: 0 additions & 9 deletions apps/web/ce/store/member/project-member.store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,4 @@ export class ProjectMemberStore extends BaseProjectMemberStore implements IProje
* @param userId - The ID of the user to remove from the project
*/
processMemberRemoval = (projectId: string, userId: string) => this.handleMemberRemoval(projectId, userId);

/**
* @description Mutate project members activity
* @param workspaceSlug
* @param projectId
*/
mutateProjectMembersActivity = async (_workspaceSlug: string, _projectId: string) => {
// No-op in default/CE version
};
}
5 changes: 0 additions & 5 deletions apps/web/core/components/project/member-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,6 @@ import { useUserPermissions } from "@/hooks/store/user";
import { MemberListFiltersDropdown } from "./dropdowns/filters/member-list";
import { ProjectMemberListItem } from "./member-list-item";
import { SendProjectInvitationModal } from "./send-project-invitation-modal";
// plane web components
import { ProjectMembersActivityButton } from "@/plane-web/components/projects/members/members-activity-button";

type TProjectMemberListProps = {
projectId: string;
Expand Down Expand Up @@ -86,7 +84,6 @@ export const ProjectMemberList = observer(function ProjectMemberList(props: TPro
className="w-full max-w-[234px] border-none bg-transparent text-13 focus:outline-none placeholder:text-placeholder"
placeholder="Search"
value={searchQuery}
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus
onChange={(e) => setSearchQuery(e.target.value)}
/>
Expand All @@ -96,11 +93,9 @@ export const ProjectMemberList = observer(function ProjectMemberList(props: TPro
handleUpdate={handleRoleFilterUpdate}
memberType="project"
/>
{isAdmin && <ProjectMembersActivityButton workspaceSlug={workspaceSlug} projectId={projectId} />}
{isAdmin && (
<Button
variant="primary"
size="lg"
onClick={() => {
setInviteModal(true);
}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { ConfirmWorkspaceMemberRemove } from "@/components/workspace/confirm-wor
// hooks
import { useMember } from "@/hooks/store/use-member";
import { useUserPermissions } from "@/hooks/store/user";
import { useWorkspace } from "@/hooks/store/use-workspace";

type Props = {
invitationId: string;
Expand All @@ -29,6 +30,7 @@ export const WorkspaceInvitationsListItem = observer(function WorkspaceInvitatio
const { t } = useTranslation();
// store hooks
const { allowPermissions, workspaceInfoBySlug } = useUserPermissions();
const { mutateWorkspaceMembersActivity } = useWorkspace();
const {
workspace: { updateMemberInvitation, deleteMemberInvitation, getWorkspaceInvitationDetails },
} = useMember();
Expand Down Expand Up @@ -57,6 +59,7 @@ export const WorkspaceInvitationsListItem = observer(function WorkspaceInvitatio
title: "Success!",
message: "Invitation removed successfully.",
});
void mutateWorkspaceMembersActivity(workspaceSlug);
} catch (err: unknown) {
const error = err as { error?: string };
setToast({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { getFileURL } from "@plane/utils";
// hooks
import { useMember } from "@/hooks/store/use-member";
import { useUser, useUserPermissions } from "@/hooks/store/user";
import { useWorkspace } from "@/hooks/store/use-workspace";
// plane web constants

export interface RowData {
Expand Down Expand Up @@ -120,6 +121,7 @@ export const AccountTypeColumn = observer(function AccountTypeColumn(props: Acco
const {
workspace: { updateMember },
} = useMember();
const { mutateWorkspaceMembersActivity } = useWorkspace();
const { data: currentUser } = useUser();

// derived values
Expand Down Expand Up @@ -154,6 +156,7 @@ export const AccountTypeColumn = observer(function AccountTypeColumn(props: Acco
await updateMember(workspaceSlug.toString(), rowData.member.id, {
role: value as unknown as EUserPermissions,
});
void mutateWorkspaceMembersActivity(workspaceSlug);
} catch (err: unknown) {
const error = err as { error?: string | string[] };
const errorString = Array.isArray(error?.error) ? error.error[0] : error?.error;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export const WorkspaceMembersListItem = observer(function WorkspaceMembersListIt
workspace: { removeMemberFromWorkspace },
} = useMember();
const { leaveWorkspace } = useUserPermissions();
const { getWorkspaceRedirectionUrl } = useWorkspace();
const { getWorkspaceRedirectionUrl, mutateWorkspaceMembersActivity } = useWorkspace();
const { fetchCurrentUserSettings } = useUserSettings();
const { t } = useTranslation();
// derived values
Expand All @@ -59,6 +59,7 @@ export const WorkspaceMembersListItem = observer(function WorkspaceMembersListIt

try {
await removeMemberFromWorkspace(workspaceSlug.toString(), memberId);
void mutateWorkspaceMembersActivity(workspaceSlug);
} catch (err: unknown) {
const error = err as { error?: string };
setToast({
Expand Down
6 changes: 2 additions & 4 deletions apps/web/core/constants/fetch-keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,10 +154,10 @@ export const APPLICATION_BY_CLIENT_ID = (clientId: string) => `APPLICATION_BY_CL
export const APPLICATION_CATEGORIES_LIST = () => `APPLICATION_CATEGORIES_LIST`;

// project level keys
export const PROJECT_DETAILS = (_workspaceSlug: string, projectId: string) =>
export const PROJECT_DETAILS = (workspaceSlug: string, projectId: string) =>
`PROJECT_DETAILS_${projectId.toString().toUpperCase()}`;

export const PROJECT_ME_INFORMATION = (_workspaceSlug: string, projectId: string) =>
export const PROJECT_ME_INFORMATION = (workspaceSlug: string, projectId: string) =>
`PROJECT_ME_INFORMATION_${projectId.toString().toUpperCase()}`;

export const PROJECT_LABELS = (projectId: string, projectRole: EUserPermissions | undefined) =>
Expand All @@ -166,8 +166,6 @@ export const PROJECT_LABELS = (projectId: string, projectRole: EUserPermissions
export const PROJECT_MEMBERS = (projectId: string, projectRole: EUserPermissions | undefined) =>
`PROJECT_MEMBERS_${projectId.toString().toUpperCase()}_${projectRole}`;

export const PROJECT_MEMBER_ACTIVITY = (projectId: string) => `PROJECT_MEMBER_ACTIVITY_${projectId.toUpperCase()}`;

export const PROJECT_STATES = (projectId: string, projectRole: EUserPermissions | undefined) =>
`PROJECT_STATES_${projectId.toString().toUpperCase()}_${projectRole}`;

Expand Down
21 changes: 6 additions & 15 deletions apps/web/core/store/member/project/base-project-member.store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,6 @@ export interface IBaseProjectMemberStore {
role: EUserProjectRoles
) => Promise<TProjectMembership>;
removeMemberFromProject: (workspaceSlug: string, projectId: string, userId: string) => Promise<void>;
mutateProjectMembersActivity: (workspaceSlug: string, projectId: string) => Promise<void>;
}

export abstract class BaseProjectMemberStore implements IBaseProjectMemberStore {
Expand Down Expand Up @@ -316,13 +315,13 @@ export abstract class BaseProjectMemberStore implements IBaseProjectMemberStore
});
});
});
update(this.projectRoot.projectMap, [projectId, "members"], (memberIds: string[]) =>
update(this.projectRoot.projectMap, [projectId, "members"], (memberIds) =>
uniq([...memberIds, ...data.members.map((m) => m.member_id)])
);
this.projectRoot.projectMap[projectId].members = this.projectRoot.projectMap?.[projectId]?.members?.concat(
data.members.map((m) => m.member_id)
);
void this.mutateProjectMembersActivity(workspaceSlug, projectId);

Comment on lines +318 to +324
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Critical: Duplicate and conflicting member updates outside runInAction.

Lines 318-320 use update() with uniq() to deduplicate members, but lines 321-323 immediately overwrite this with a direct mutation that:

  1. Executes outside the runInAction block (which ends at line 317), violating MobX patterns
  2. Doesn't deduplicate, potentially introducing duplicate member IDs
  3. Directly mutates observable state without proper action wrapping
  4. Makes the first update (lines 318-320) redundant
🔎 Proposed fix: Remove the redundant direct mutation
      update(this.projectRoot.projectMap, [projectId, "members"], (memberIds) =>
        uniq([...memberIds, ...data.members.map((m) => m.member_id)])
      );
-      this.projectRoot.projectMap[projectId].members = this.projectRoot.projectMap?.[projectId]?.members?.concat(
-        data.members.map((m) => m.member_id)
-      );

      return response;

Alternatively, if you need both updates for some reason, wrap the second one in runInAction:

      update(this.projectRoot.projectMap, [projectId, "members"], (memberIds) =>
        uniq([...memberIds, ...data.members.map((m) => m.member_id)])
      );
-      this.projectRoot.projectMap[projectId].members = this.projectRoot.projectMap?.[projectId]?.members?.concat(
-        data.members.map((m) => m.member_id)
-      );
+      runInAction(() => {
+        set(this.projectRoot.projectMap, [projectId, "members"], 
+          uniq([...(this.projectRoot.projectMap?.[projectId]?.members ?? []), ...data.members.map((m) => m.member_id)])
+        );
+      });

      return response;

However, the first solution (removing lines 321-323) is preferred since the update() call already handles the mutation correctly.

🤖 Prompt for AI Agents
In apps/web/core/store/member/project/base-project-member.store.ts around lines
318 to 324, there is a redundant direct mutation that overwrites the prior
update: remove the direct assignment block (lines 321-323) that does
this.projectRoot.projectMap[projectId].members = ... since update(...) with
uniq(...) already performs the correct, deduplicated mutation inside the
runInAction; if you must keep a second assignment for some reason, instead wrap
it in runInAction and apply uniq to its result to avoid duplicates and avoid
mutating observables outside an action.

return response;
});

Expand Down Expand Up @@ -376,7 +375,6 @@ export abstract class BaseProjectMemberStore implements IBaseProjectMemberStore
role,
}
);
void this.mutateProjectMembersActivity(workspaceSlug, projectId);
return response;
} catch (error) {
// revert back to original members in case of error
Expand Down Expand Up @@ -431,11 +429,11 @@ export abstract class BaseProjectMemberStore implements IBaseProjectMemberStore
removeMemberFromProject = async (workspaceSlug: string, projectId: string, userId: string) => {
const memberDetails = this.getProjectMemberDetails(userId, projectId);
if (!memberDetails || !memberDetails?.id) throw new Error("Member not found");
await this.projectMemberService.deleteProjectMember(workspaceSlug, projectId, memberDetails?.id);
runInAction(() => {
this.processMemberRemoval(projectId, userId);
await this.projectMemberService.deleteProjectMember(workspaceSlug, projectId, memberDetails?.id).then(() => {
runInAction(() => {
this.processMemberRemoval(projectId, userId);
});
});
void this.mutateProjectMembersActivity(workspaceSlug, projectId);
};

/**
Expand Down Expand Up @@ -503,11 +501,4 @@ export abstract class BaseProjectMemberStore implements IBaseProjectMemberStore
throw error;
}
};

/**
* Mutate project members activity
* @param workspaceSlug
* @param projectId
*/
abstract mutateProjectMembersActivity(workspaceSlug: string, projectId: string): Promise<void>;
}
38 changes: 17 additions & 21 deletions apps/web/core/store/member/workspace/workspace-member.store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@ import { computedFn } from "mobx-utils";
// types
import type { EUserPermissions } from "@plane/constants";
import type { IWorkspaceBulkInviteFormData, IWorkspaceMember, IWorkspaceMemberInvitation } from "@plane/types";
// plane-web imports
import type { RootStore } from "@/plane-web/store/root.store";
// plane-web constants
// services
import { WorkspaceService } from "@/plane-web/services";
// types
import type { IRouterStore } from "@/store/router.store";
import type { IUserStore } from "@/store/user";
// store
import type { CoreRootStore } from "../../root.store";
import type { IMemberRootStore } from "../index.ts";
import type { IWorkspaceMemberFiltersStore } from "./workspace-member-filters.store";
import { WorkspaceMemberFiltersStore } from "./workspace-member-filters.store";
Expand Down Expand Up @@ -69,11 +69,10 @@ export class WorkspaceMemberStore implements IWorkspaceMemberStore {
routerStore: IRouterStore;
userStore: IUserStore;
memberRoot: IMemberRootStore;
rootStore: RootStore;
// services
workspaceService;

constructor(_memberRoot: IMemberRootStore, _rootStore: RootStore) {
constructor(_memberRoot: IMemberRootStore, _rootStore: CoreRootStore) {
makeObservable(this, {
// observables
workspaceMemberMap: observable,
Expand All @@ -96,7 +95,6 @@ export class WorkspaceMemberStore implements IWorkspaceMemberStore {
this.routerStore = _rootStore.router;
this.userStore = _rootStore.user;
this.memberRoot = _memberRoot;
this.rootStore = _rootStore;
// services
this.workspaceService = new WorkspaceService();
}
Expand Down Expand Up @@ -261,7 +259,6 @@ export class WorkspaceMemberStore implements IWorkspaceMemberStore {
set(this.workspaceMemberMap, [workspaceSlug, userId, "role"], data.role);
});
await this.workspaceService.updateWorkspaceMember(workspaceSlug, memberDetails.id, data);
void this.rootStore.workspaceRoot.mutateWorkspaceMembersActivity(workspaceSlug);
} catch (error) {
// revert back to original members in case of error
runInAction(() => {
Expand All @@ -279,11 +276,11 @@ export class WorkspaceMemberStore implements IWorkspaceMemberStore {
removeMemberFromWorkspace = async (workspaceSlug: string, userId: string) => {
const memberDetails = this.getWorkspaceMemberDetails(userId);
if (!memberDetails) throw new Error("Member not found");
await this.workspaceService.deleteWorkspaceMember(workspaceSlug, memberDetails?.id);
runInAction(() => {
set(this.workspaceMemberMap, [workspaceSlug, userId, "is_active"], false);
await this.workspaceService.deleteWorkspaceMember(workspaceSlug, memberDetails?.id).then(() => {
runInAction(() => {
set(this.workspaceMemberMap, [workspaceSlug, userId, "is_active"], false);
});
});
void this.rootStore.workspaceRoot.mutateWorkspaceMembersActivity(workspaceSlug);
};

/**
Expand All @@ -304,9 +301,9 @@ export class WorkspaceMemberStore implements IWorkspaceMemberStore {
* @param data
*/
inviteMembersToWorkspace = async (workspaceSlug: string, data: IWorkspaceBulkInviteFormData) => {
await this.workspaceService.inviteWorkspace(workspaceSlug, data);
const response = await this.workspaceService.inviteWorkspace(workspaceSlug, data);
await this.fetchWorkspaceMemberInvitations(workspaceSlug);
void this.rootStore.workspaceRoot.mutateWorkspaceMembersActivity(workspaceSlug);
return response;
};
Comment on lines 303 to 307
Copy link

Copilot AI Dec 30, 2025

Choose a reason for hiding this comment

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

The interface declares that inviteMembersToWorkspace returns Promise<void>, but the implementation now returns response. The return type in the interface should be updated to match the implementation, or the implementation should be changed to not return the response.

Copilot uses AI. Check for mistakes.

/**
Expand All @@ -320,7 +317,7 @@ export class WorkspaceMemberStore implements IWorkspaceMemberStore {
invitationId: string,
data: Partial<IWorkspaceMemberInvitation>
) => {
const originalMemberInvitations = [...(this.workspaceMemberInvitations?.[workspaceSlug] ?? [])]; // in case of error, we will revert back to original members
const originalMemberInvitations = [...this.workspaceMemberInvitations?.[workspaceSlug]]; // in case of error, we will revert back to original members
Copy link

Copilot AI Dec 30, 2025

Choose a reason for hiding this comment

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

The spread operator on line 320 may throw a runtime error if this.workspaceMemberInvitations?.[workspaceSlug] is undefined. The optional chaining will return undefined, and spreading undefined will cause an error. Consider using the nullish coalescing operator to provide a fallback empty array.

Suggested change
const originalMemberInvitations = [...this.workspaceMemberInvitations?.[workspaceSlug]]; // in case of error, we will revert back to original members
const originalMemberInvitations = [...(this.workspaceMemberInvitations?.[workspaceSlug] ?? [])]; // in case of error, we will revert back to original members

Copilot uses AI. Check for mistakes.
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Unsafe optional chaining can throw TypeError.

If this.workspaceMemberInvitations?.[workspaceSlug] short-circuits to undefined, the spread operator will throw a TypeError: undefined is not iterable. The static analysis tool correctly flagged this.

🔎 Proposed fix
-    const originalMemberInvitations = [...this.workspaceMemberInvitations?.[workspaceSlug]]; // in case of error, we will revert back to original members
+    const originalMemberInvitations = [...(this.workspaceMemberInvitations?.[workspaceSlug] ?? [])]; // in case of error, we will revert back to original members
🧰 Tools
🪛 Biome (2.1.2)

[error] 320-320: Unsafe usage of optional chaining.

If it short-circuits with 'undefined' the evaluation will throw TypeError here:

(lint/correctness/noUnsafeOptionalChaining)

🤖 Prompt for AI Agents
In apps/web/core/store/member/workspace/workspace-member.store.ts around line
320, the spread uses this.workspaceMemberInvitations?.[workspaceSlug] which can
be undefined and cause "undefined is not iterable"; change the expression to
default to an empty array before spreading (e.g., use nullish coalescing or
logical OR) so the spread always receives an iterable and preserves the original
invitations safely.

try {
const memberInvitations = originalMemberInvitations?.map((invitation) => ({
...invitation,
Expand All @@ -345,15 +342,14 @@ export class WorkspaceMemberStore implements IWorkspaceMemberStore {
* @param workspaceSlug
* @param memberId
*/
deleteMemberInvitation = async (workspaceSlug: string, invitationId: string) => {
await this.workspaceService.deleteWorkspaceInvitations(workspaceSlug.toString(), invitationId);
runInAction(() => {
this.workspaceMemberInvitations[workspaceSlug] = this.workspaceMemberInvitations[workspaceSlug].filter(
(inv) => inv.id !== invitationId
);
deleteMemberInvitation = async (workspaceSlug: string, invitationId: string) =>
await this.workspaceService.deleteWorkspaceInvitations(workspaceSlug.toString(), invitationId).then(() => {
runInAction(() => {
this.workspaceMemberInvitations[workspaceSlug] = this.workspaceMemberInvitations[workspaceSlug].filter(
(inv) => inv.id !== invitationId
);
});
});
void this.rootStore.workspaceRoot.mutateWorkspaceMembersActivity(workspaceSlug);
};

isUserSuspended = computedFn((userId: string, workspaceSlug: string) => {
if (!workspaceSlug) return false;
Expand Down
Loading