({
bindings,
allowEdit,
selectedBindings,
onSelectionChange,
onUpdateBinding,
onRevokeBinding,
scope,
}: {
bindings: MemberBinding[];
allowEdit: boolean;
selectedBindings: string[];
onSelectionChange: (selected: string[]) => void;
onUpdateBinding: (binding: MemberBinding) => void;
onRevokeBinding: (binding: MemberBinding) => void;
scope: "workspace" | "project";
})
| 146 | // ============================================================ |
| 147 | |
| 148 | function MemberTable({ |
| 149 | bindings, |
| 150 | allowEdit, |
| 151 | selectedBindings, |
| 152 | onSelectionChange, |
| 153 | onUpdateBinding, |
| 154 | onRevokeBinding, |
| 155 | scope, |
| 156 | }: { |
| 157 | bindings: MemberBinding[]; |
| 158 | allowEdit: boolean; |
| 159 | selectedBindings: string[]; |
| 160 | onSelectionChange: (selected: string[]) => void; |
| 161 | onUpdateBinding: (binding: MemberBinding) => void; |
| 162 | onRevokeBinding: (binding: MemberBinding) => void; |
| 163 | scope: "workspace" | "project"; |
| 164 | }) { |
| 165 | const { t } = useTranslation(); |
| 166 | const currentUser = useCurrentUser(); |
| 167 | const isSaaSMode = useAppStore((s) => s.isSaaSMode()); |
| 168 | const batchGetOrFetchUsers = useAppStore( |
| 169 | (state) => state.batchGetOrFetchUsers |
| 170 | ); |
| 171 | const roleList = useAppStore((state) => state.roleList); |
| 172 | const navigate = useNavigate(); |
| 173 | const canGetGroups = hasWorkspacePermissionV2("bb.groups.get"); |
| 174 | const canGetUsers = hasWorkspacePermissionV2("bb.users.get"); |
| 175 | |
| 176 | // Group expand state. Cache is keyed by group name and invalidated |
| 177 | // when the group-binding *content* changes — not on `bindings` |
| 178 | // reference change, because the parent rebuilds `memberBindings` via |
| 179 | // `useMemo(() => getMemberBindings(...))` and gets a new array identity |
| 180 | // whenever its deps change. We can't use the `prevBindingsRef.current |
| 181 | // !== bindings` shortcut that `GroupsPage` uses (its `groups` comes |
| 182 | // from a reducer-backed `usePagedData` with stable identity). |
| 183 | // Comparing a content-derived signature instead lets the cache only |
| 184 | // reset on real membership changes — same effect, different trigger. |
| 185 | const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set()); |
| 186 | const [memberCache, setMemberCache] = useState<Map<string, User[]>>( |
| 187 | new Map() |
| 188 | ); |
| 189 | const memberCacheRef = useRef(memberCache); |
| 190 | memberCacheRef.current = memberCache; |
| 191 | const loadingRef = useRef<Set<string>>(new Set()); |
| 192 | |
| 193 | const fetchGroupMembers = useCallback( |
| 194 | (group: GroupBinding) => { |
| 195 | if (loadingRef.current.has(group.name)) return; |
| 196 | loadingRef.current.add(group.name); |
| 197 | const memberNames = group.members.map((m) => m.member); |
| 198 | batchGetOrFetchUsers(memberNames) |
| 199 | .then((users: (User | undefined)[]) => { |
| 200 | setMemberCache((prev) => { |
| 201 | const next = new Map(prev); |
| 202 | next.set( |
| 203 | group.name, |
| 204 | users.filter((u): u is User => !!u) |
| 205 | ); |
nothing calls this directly
no test coverage detected