From 1a996cc244c275ab4d92b17fdaf858ff58dd2c45 Mon Sep 17 00:00:00 2001
From: SteRiccio <1219739+SteRiccio@users.noreply.github.com>
Date: Thu, 23 Jul 2026 09:40:13 +0200
Subject: [PATCH 1/8] improved user group overview update
---
.../UserGroupsSummary/UserCard.tsx | 10 +-
.../UserGroupsSummary/UserGroupColumn.tsx | 12 ++-
.../UserGroupsSummary/UserGroupsSummary.scss | 5 +
.../UserGroupsSummary/UserGroupsSummary.tsx | 4 +-
.../useUserGroupsKanbanDnd.ts | 53 +++++------
.../UserGroupsSummary/useUserGroupsSummary.ts | 94 +++++++++++++------
6 files changed, 112 insertions(+), 66 deletions(-)
diff --git a/webapp/views/App/views/Users/UserGroupsOverview/UserGroupsSummary/UserCard.tsx b/webapp/views/App/views/Users/UserGroupsOverview/UserGroupsSummary/UserCard.tsx
index e67cc11ff0..05025e1c69 100644
--- a/webapp/views/App/views/Users/UserGroupsOverview/UserGroupsSummary/UserCard.tsx
+++ b/webapp/views/App/views/Users/UserGroupsOverview/UserGroupsSummary/UserCard.tsx
@@ -9,6 +9,7 @@ import type { SurveyUserType } from './useUserGroupsSummary'
type Props = {
user: SurveyUserType
draggable: boolean
+ pending?: boolean
}
/**
@@ -19,14 +20,19 @@ type Props = {
* @param props0 - The component props.
* @param props0.user - The survey user to render.
* @param props0.draggable - Whether the card can be dragged; false renders a static, non-interactive card.
+ * @param props0.pending - Whether a group change for this user is currently being saved; renders a dimmed, non-interactive card regardless of `draggable`, so it can't be dragged again until the change settles.
* @returns {React.ReactElement} - The UserCard component.
*/
const UserCard = (props: Props): React.ReactElement => {
- const { user, draggable } = props
+ const { user, draggable, pending = false } = props
const userUuid = User.getUuid(user) as string
+ const className = ['user-card', draggable && 'user-card--draggable', pending && 'user-card--pending']
+ .filter(Boolean)
+ .join(' ')
+
return (
-
+
{User.getName(user)}
diff --git a/webapp/views/App/views/Users/UserGroupsOverview/UserGroupsSummary/UserGroupColumn.tsx b/webapp/views/App/views/Users/UserGroupsOverview/UserGroupsSummary/UserGroupColumn.tsx
index 3a5c2af4ac..0f43c7fc28 100644
--- a/webapp/views/App/views/Users/UserGroupsOverview/UserGroupsSummary/UserGroupColumn.tsx
+++ b/webapp/views/App/views/Users/UserGroupsOverview/UserGroupsSummary/UserGroupColumn.tsx
@@ -16,6 +16,7 @@ type Props = {
groupKey: string
members: SurveyUserType[]
draggable: boolean
+ pendingUserUuids: Set
containerRef: (el: HTMLUListElement | null) => void
}
@@ -28,11 +29,12 @@ type Props = {
* @param props0.groupKey - The group's uuid, or the UNASSIGNED_GROUP_KEY sentinel; set as the column's data-group-uuid attribute.
* @param props0.members - The survey users currently assigned to this column.
* @param props0.draggable - Whether the cards in this column can be dragged.
+ * @param props0.pendingUserUuids - Uuids of users with a group change currently in flight; their cards render as non-draggable until it settles.
* @param props0.containerRef - Callback ref registering this column's drop-target list element with the drag-and-drop hook.
* @returns {React.ReactElement} - The UserGroupColumn component.
*/
const UserGroupColumn = (props: Props): React.ReactElement => {
- const { group, groupKey, members, draggable, containerRef } = props
+ const { group, groupKey, members, draggable, pendingUserUuids, containerRef } = props
const i18n = useI18n()
const preferredLang = useSurveyPreferredLang() as string
@@ -50,9 +52,11 @@ const UserGroupColumn = (props: Props): React.ReactElement => {
{members.length === 0 && (
{i18n.t('usersView:userGroup.noMembers')}
)}
- {members.map((user) => (
-
- ))}
+ {members.map((user) => {
+ const userUuid = User.getUuid(user) as string
+ const pending = pendingUserUuids.has(userUuid)
+ return
+ })}
)
diff --git a/webapp/views/App/views/Users/UserGroupsOverview/UserGroupsSummary/UserGroupsSummary.scss b/webapp/views/App/views/Users/UserGroupsOverview/UserGroupsSummary/UserGroupsSummary.scss
index c2d5f8df4e..643adb847d 100644
--- a/webapp/views/App/views/Users/UserGroupsOverview/UserGroupsSummary/UserGroupsSummary.scss
+++ b/webapp/views/App/views/Users/UserGroupsOverview/UserGroupsSummary/UserGroupsSummary.scss
@@ -84,6 +84,11 @@
cursor: grab;
}
+ &--pending {
+ opacity: 0.6;
+ cursor: wait;
+ }
+
&.draggable-source--is-dragging {
opacity: 0.4;
}
diff --git a/webapp/views/App/views/Users/UserGroupsOverview/UserGroupsSummary/UserGroupsSummary.tsx b/webapp/views/App/views/Users/UserGroupsOverview/UserGroupsSummary/UserGroupsSummary.tsx
index 0d98155d2b..d0d55f6745 100644
--- a/webapp/views/App/views/Users/UserGroupsOverview/UserGroupsSummary/UserGroupsSummary.tsx
+++ b/webapp/views/App/views/Users/UserGroupsOverview/UserGroupsSummary/UserGroupsSummary.tsx
@@ -30,7 +30,7 @@ interface ColumnData {
*/
const UserGroupsSummary = (): React.ReactElement => {
const canManage = useAuthCanManageUserGroups()
- const { groups, users, groupUuidByUserUuid, onChangeUserGroup, reload } = useUserGroupsSummary()
+ const { groups, users, groupUuidByUserUuid, onChangeUserGroup, pendingUserUuids } = useUserGroupsSummary()
const columns: ColumnData[] = useMemo(() => {
const unassignedMembers = users.filter((user) => !groupUuidByUserUuid[User.getUuid(user) as string])
@@ -49,7 +49,6 @@ const UserGroupsSummary = (): React.ReactElement => {
enabled: canManage,
columnKeys: columns.map((column) => column.key),
onChangeUserGroup,
- reload,
unassignedGroupKey: UNASSIGNED_GROUP_KEY,
})
@@ -62,6 +61,7 @@ const UserGroupsSummary = (): React.ReactElement => {
group={column.group}
members={column.members}
draggable={canManage}
+ pendingUserUuids={pendingUserUuids}
containerRef={registerColumnRef(column.key)}
/>
))}
diff --git a/webapp/views/App/views/Users/UserGroupsOverview/UserGroupsSummary/useUserGroupsKanbanDnd.ts b/webapp/views/App/views/Users/UserGroupsOverview/UserGroupsSummary/useUserGroupsKanbanDnd.ts
index 57fa8802c9..b2cdb3ab0e 100644
--- a/webapp/views/App/views/Users/UserGroupsOverview/UserGroupsSummary/useUserGroupsKanbanDnd.ts
+++ b/webapp/views/App/views/Users/UserGroupsOverview/UserGroupsSummary/useUserGroupsKanbanDnd.ts
@@ -8,7 +8,6 @@ interface UseUserGroupsKanbanDndParams {
enabled: boolean
columnKeys: string[]
onChangeUserGroup: OnChangeUserGroup
- reload: () => Promise
unassignedGroupKey: string
}
@@ -29,37 +28,35 @@ interface UseUserGroupsKanbanDndResult {
* cross-column handling below is therefore deferred to a microtask, so it runs once that
* synchronous continuation has completed and the element has actually been relocated; only then
* is it safe to move it back to its original column so the DOM still matches what React currently
- * believes is true, letting the subsequent state-driven re-render (whether onChangeUserGroup
- * succeeds or, via the reload fallback, if it fails) reconcile the real move safely instead of
- * crashing on a stale DOM parent reference.
+ * believes is true, letting the subsequent state-driven re-render - triggered by onChangeUserGroup
+ * optimistically updating group membership before it even calls the server, and again if it later
+ * rolls that back on failure - reconcile the real move safely instead of crashing on a stale DOM
+ * parent reference.
*
* @param params0 - The hook parameters.
* @param params0.enabled - Whether drag-and-drop should be active; when false, Sortable is never instantiated and cards render inert.
* @param params0.columnKeys - The current ordered list of column keys (group uuids plus the unassigned sentinel); Sortable is reinitialized whenever this list changes shape.
- * @param params0.onChangeUserGroup - Called with the dragged user's uuid and the destination column's group uuid (or null) on a cross-column drop.
- * @param params0.reload - Re-fetches board data; called if onChangeUserGroup rejects, to force a state-driven re-render that reconciles the DOM.
+ * @param params0.onChangeUserGroup - Called with the dragged user's uuid and the destination column's group uuid (or null) on a cross-column drop; expected to update membership state optimistically and roll it back itself on failure.
* @param params0.unassignedGroupKey - The sentinel value used as the Unassigned column's data-group-uuid, mapped to null before calling onChangeUserGroup.
* @returns {UseUserGroupsKanbanDndResult} An object exposing registerColumnRef, used to obtain a stable callback ref for each column's drop-target element.
*/
export const useUserGroupsKanbanDnd = (params: UseUserGroupsKanbanDndParams): UseUserGroupsKanbanDndResult => {
- const { enabled, columnKeys, onChangeUserGroup, reload, unassignedGroupKey } = params
+ const { enabled, columnKeys, onChangeUserGroup, unassignedGroupKey } = params
const containersByKeyRef = useRef