@@ -38,6 +56,9 @@ export const Tabs = (props) => {
}
Tabs.propTypes = {
+ fullWidth: PropTypes.bool,
items: PropTypes.array.isRequired,
+ onChange: PropTypes.func,
orientation: PropTypes.oneOf(['vertical', 'horizontal']),
+ selectedItemKey: PropTypes.string,
}
diff --git a/webapp/views/App/views/Users/UserGroupEdit/UserGroupEdit.scss b/webapp/views/App/views/UserGroups/UserGroupEdit/UserGroupEdit.scss
similarity index 100%
rename from webapp/views/App/views/Users/UserGroupEdit/UserGroupEdit.scss
rename to webapp/views/App/views/UserGroups/UserGroupEdit/UserGroupEdit.scss
diff --git a/webapp/views/App/views/Users/UserGroupEdit/UserGroupEdit.tsx b/webapp/views/App/views/UserGroups/UserGroupEdit/UserGroupEdit.tsx
similarity index 100%
rename from webapp/views/App/views/Users/UserGroupEdit/UserGroupEdit.tsx
rename to webapp/views/App/views/UserGroups/UserGroupEdit/UserGroupEdit.tsx
diff --git a/webapp/views/App/views/Users/UserGroupEdit/UserGroupMembersEditor/UserGroupMembersEditor.tsx b/webapp/views/App/views/UserGroups/UserGroupEdit/UserGroupMembersEditor/UserGroupMembersEditor.tsx
similarity index 100%
rename from webapp/views/App/views/Users/UserGroupEdit/UserGroupMembersEditor/UserGroupMembersEditor.tsx
rename to webapp/views/App/views/UserGroups/UserGroupEdit/UserGroupMembersEditor/UserGroupMembersEditor.tsx
diff --git a/webapp/views/App/views/Users/UserGroupEdit/UserGroupMembersEditor/index.ts b/webapp/views/App/views/UserGroups/UserGroupEdit/UserGroupMembersEditor/index.ts
similarity index 100%
rename from webapp/views/App/views/Users/UserGroupEdit/UserGroupMembersEditor/index.ts
rename to webapp/views/App/views/UserGroups/UserGroupEdit/UserGroupMembersEditor/index.ts
diff --git a/webapp/views/App/views/Users/UserGroupEdit/UserGroupMembersEditor/useUserGroupMembersEditor.ts b/webapp/views/App/views/UserGroups/UserGroupEdit/UserGroupMembersEditor/useUserGroupMembersEditor.ts
similarity index 99%
rename from webapp/views/App/views/Users/UserGroupEdit/UserGroupMembersEditor/useUserGroupMembersEditor.ts
rename to webapp/views/App/views/UserGroups/UserGroupEdit/UserGroupMembersEditor/useUserGroupMembersEditor.ts
index 1cf432f3af..73f32fe565 100644
--- a/webapp/views/App/views/Users/UserGroupEdit/UserGroupMembersEditor/useUserGroupMembersEditor.ts
+++ b/webapp/views/App/views/UserGroups/UserGroupEdit/UserGroupMembersEditor/useUserGroupMembersEditor.ts
@@ -186,7 +186,7 @@ export const useUserGroupMembersEditor = ({
// Adds the user to this group. When `oldGroupUuid` is given (reassignment case), removes them
// from that other group FIRST, then adds them to this one, so the user never ends up (even
// momentarily as far as the persisted end-state is concerned) in both groups at once, matching
- // the remove-then-add sequencing in useUserGroupsSummary.ts's onChangeUserGroup.
+ // the remove-then-add sequencing in useUserGroupsOverview.ts's onChangeUserGroup.
const addMember = useCallback(
async (userUuid: string, oldGroupUuid?: string) => {
try {
diff --git a/webapp/views/App/views/Users/UserGroupEdit/UserGroupQualifiersEditor/UserGroupQualifiersEditor.tsx b/webapp/views/App/views/UserGroups/UserGroupEdit/UserGroupQualifiersEditor/UserGroupQualifiersEditor.tsx
similarity index 100%
rename from webapp/views/App/views/Users/UserGroupEdit/UserGroupQualifiersEditor/UserGroupQualifiersEditor.tsx
rename to webapp/views/App/views/UserGroups/UserGroupEdit/UserGroupQualifiersEditor/UserGroupQualifiersEditor.tsx
diff --git a/webapp/views/App/views/Users/UserGroupEdit/UserGroupQualifiersEditor/index.ts b/webapp/views/App/views/UserGroups/UserGroupEdit/UserGroupQualifiersEditor/index.ts
similarity index 100%
rename from webapp/views/App/views/Users/UserGroupEdit/UserGroupQualifiersEditor/index.ts
rename to webapp/views/App/views/UserGroups/UserGroupEdit/UserGroupQualifiersEditor/index.ts
diff --git a/webapp/views/App/views/Users/UserGroupEdit/index.tsx b/webapp/views/App/views/UserGroups/UserGroupEdit/index.tsx
similarity index 100%
rename from webapp/views/App/views/Users/UserGroupEdit/index.tsx
rename to webapp/views/App/views/UserGroups/UserGroupEdit/index.tsx
diff --git a/webapp/views/App/views/Users/UserGroupEdit/useEditUserGroup.ts b/webapp/views/App/views/UserGroups/UserGroupEdit/useEditUserGroup.ts
similarity index 99%
rename from webapp/views/App/views/Users/UserGroupEdit/useEditUserGroup.ts
rename to webapp/views/App/views/UserGroups/UserGroupEdit/useEditUserGroup.ts
index 4b540a3438..db3a343551 100644
--- a/webapp/views/App/views/Users/UserGroupEdit/useEditUserGroup.ts
+++ b/webapp/views/App/views/UserGroups/UserGroupEdit/useEditUserGroup.ts
@@ -76,7 +76,7 @@ export const useEditUserGroup = ({ groupUuid }: UseEditUserGroupParams): UseEdit
// Pure data fetcher: never sets state itself, so it's safe to call from the reactive effect below
// via a staleness-guarded `.then()`, following the pattern established in
// UserGroupQualifiersEditor.tsx / Task 11 and reused in useUserGroupMembersEditor.ts /
- // useUserGroupsSummary.ts.
+ // useUserGroupsOverview.ts.
const fetchGroups = useCallback(() => API.fetchUserGroups({ surveyId }), [surveyId])
// Reactive load: re-fetches whenever the group or survey changes. Uses the `.then()`-callback
diff --git a/webapp/views/App/views/UserGroups/UserGroupsEditor/UserGroupsEditor.scss b/webapp/views/App/views/UserGroups/UserGroupsEditor/UserGroupsEditor.scss
new file mode 100644
index 0000000000..e970d375d0
--- /dev/null
+++ b/webapp/views/App/views/UserGroups/UserGroupsEditor/UserGroupsEditor.scss
@@ -0,0 +1,56 @@
+.app__container .app-module > .user-groups-editor {
+ // every tab manages its own internal scrolling instead (the DataGrid's built-in row scroller,
+ // header and pagination staying fixed, for the groups list/report tabs; the Kanban board's own
+ // per-column scroller for the assignments tab), so the outer page-level scroll set up
+ // generically in AppView.scss must be disabled here.
+ overflow: hidden;
+}
+
+.user-groups-editor {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+
+ &__bar {
+ display: flex;
+ gap: 1rem;
+ // fills the page's remaining height (below), stretching .tabs-wrapper (default alignment)
+ // to that same height so it can pass a definite height down to whichever tab is active.
+ flex: 1;
+ min-height: 0;
+
+ .tabs-wrapper {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+ // flex items default to a min-width based on their content's intrinsic width, so a wide
+ // tab (e.g. the members report table) would otherwise force this row - and the tab strip
+ // with it - wider than the page instead of scrolling internally.
+ min-width: 0;
+ }
+ }
+
+ &__add {
+ // opt out of the bar's stretch alignment so the button keeps its natural height and stays
+ // pinned to the top-right corner instead of stretching down the full height of the row.
+ align-self: flex-start;
+ }
+
+ .MuiTabs-root {
+ flex: 0 0 auto;
+ }
+
+ .tab-content {
+ flex: 1;
+ min-height: 0;
+ overflow: hidden;
+
+ // fills the tab-content area so the DataGrid/Kanban board computes its own scrollable
+ // height instead of growing to fit all of its rows/members.
+ .user-groups-overview,
+ .data-grid {
+ height: 100%;
+ }
+ }
+}
diff --git a/webapp/views/App/views/UserGroups/UserGroupsEditor/UserGroupsEditor.tsx b/webapp/views/App/views/UserGroups/UserGroupsEditor/UserGroupsEditor.tsx
new file mode 100644
index 0000000000..7d8f449e51
--- /dev/null
+++ b/webapp/views/App/views/UserGroups/UserGroupsEditor/UserGroupsEditor.tsx
@@ -0,0 +1,100 @@
+import './UserGroupsEditor.scss'
+
+import React from 'react'
+import { useLocation, useNavigate } from 'react-router-dom'
+
+import { useAuthCanManageUserGroups } from '@webapp/store/user'
+import { ButtonIconAdd } from '@webapp/components'
+import { Tabs } from '@webapp/components/Tabs'
+import { useQuery } from '@webapp/components/hooks'
+import { appModuleUri, userModules } from '@webapp/app/appModules'
+
+import UserGroupsList from './UserGroupsList'
+import UserGroupsOverview from './UserGroupsOverview'
+import UserGroupsTable from './UserGroupsTable'
+
+// appModules.js is a plain JS module without explicit types: TS infers appModuleUri's parameter shape
+// from its default value (appModules.home), which happens to include an `icon` field that userModules
+// entries don't have (and that appModuleUri never reads). Cast to the function's own inferred parameter
+// type rather than editing that shared, out-of-scope module.
+type AppModule = Parameters[0]
+
+const ViewMode = {
+ groups: 'groups',
+ assignments: 'assignments',
+ report: 'report',
+} as const
+
+type ViewModeType = (typeof ViewMode)[keyof typeof ViewMode]
+
+const isViewMode = (value: unknown): value is ViewModeType =>
+ value === ViewMode.groups || value === ViewMode.assignments || value === ViewMode.report
+
+/**
+ * User Groups management page for a survey: toggles between the plain groups list
+ * (ViewMode.groups), a Kanban-style board of every survey user's group assignment
+ * (ViewMode.assignments, rendered by UserGroupsOverview) and a flat report table with one row per
+ * group-member pair (ViewMode.report). The assignments and report view modes are only shown to
+ * users who can manage user groups; other users only ever see the groups list. The "New Group"
+ * button is shown in every view mode to users allowed to manage groups. The active view mode is
+ * kept in the URL's `view` query param (replacing history rather than pushing) so that the "Back"
+ * button on the create/edit group page - a plain `navigate(-1)` - lands back on whichever tab the
+ * user came from. The page fills the full available height (see UserGroupsEditor.scss) so that
+ * every tab's content can scroll internally within its own bounds - the groups list/report
+ * DataGrids scroll their rows with the header and pagination footer staying fixed in place, and
+ * the assignments Kanban board scrolls each column independently - instead of growing the whole
+ * page.
+ *
+ * @returns {React.ReactElement} - The UserGroupsEditor component.
+ */
+const UserGroupsEditor = (): React.ReactElement => {
+ const navigate = useNavigate()
+ const location = useLocation()
+ const canManage = useAuthCanManageUserGroups()
+ // useQuery's untyped implementation makes TS infer its return type as `{}`; cast to the
+ // `Record` shape its `URLSearchParams`-driven implementation actually returns.
+ const query = useQuery() as Record
+ const viewMode = isViewMode(query.view) ? query.view : ViewMode.groups
+
+ const effectiveViewMode = canManage ? viewMode : ViewMode.groups
+
+ const setViewMode = (mode: ViewModeType): void => {
+ const search = mode === ViewMode.groups ? '' : `?view=${mode}`
+ navigate({ pathname: location.pathname, search }, { replace: true })
+ }
+
+ // The assignments and report tabs are only offered to users who can manage user groups; other
+ // users only ever see the groups list, so its tab is the only one always present.
+ const tabItems = [
+ { key: ViewMode.groups, label: 'usersView:userGroup.tabs.groups', renderContent: () => },
+ ...(canManage
+ ? [
+ {
+ key: ViewMode.assignments,
+ label: 'usersView:userGroup.tabs.assignments',
+ renderContent: () => ,
+ },
+ { key: ViewMode.report, label: 'usersView:userGroup.tabs.report', renderContent: () => },
+ ]
+ : []),
+ ]
+
+ return (
+
+ )
+}
+
+export default UserGroupsEditor
diff --git a/webapp/views/App/views/Users/UserGroupsOverview/UserGroupsList/UserGroupsList.scss b/webapp/views/App/views/UserGroups/UserGroupsEditor/UserGroupsList/UserGroupsList.scss
similarity index 100%
rename from webapp/views/App/views/Users/UserGroupsOverview/UserGroupsList/UserGroupsList.scss
rename to webapp/views/App/views/UserGroups/UserGroupsEditor/UserGroupsList/UserGroupsList.scss
diff --git a/webapp/views/App/views/UserGroups/UserGroupsEditor/UserGroupsList/UserGroupsList.tsx b/webapp/views/App/views/UserGroups/UserGroupsEditor/UserGroupsList/UserGroupsList.tsx
new file mode 100644
index 0000000000..c996308459
--- /dev/null
+++ b/webapp/views/App/views/UserGroups/UserGroupsEditor/UserGroupsList/UserGroupsList.tsx
@@ -0,0 +1,80 @@
+import './UserGroupsList.scss'
+
+import React, { useMemo } from 'react'
+import { useNavigate } from 'react-router-dom'
+import { GridColDef, GridRowParams } from '@mui/x-data-grid'
+
+import * as UserGroup from '@core/user/userGroup/userGroup'
+import { appModuleUri, userModules } from '@webapp/app/appModules'
+import { useSurveyPreferredLang } from '@webapp/store/survey'
+import { useI18n } from '@webapp/store/system'
+import { DataGrid } from '@webapp/components/DataGrid'
+import LoadingBar from '@webapp/components/LoadingBar'
+
+import { useUserGroupsList } from './useUserGroupsList'
+
+// appModules.js is a plain JS module without explicit types: TS infers appModuleUri's parameter shape
+// from its default value (appModules.home), which happens to include an `icon` field that userModules
+// entries don't have (and that appModuleUri never reads). Cast to the function's own inferred parameter
+// type rather than editing that shared, out-of-scope module.
+type AppModule = Parameters[0]
+
+/**
+ * User Groups list table, listing the user groups defined in the current survey.
+ *
+ * @returns {React.ReactElement} - The UserGroupsList component.
+ */
+const UserGroupsList = (): React.ReactElement => {
+ const i18n = useI18n()
+ const navigate = useNavigate()
+ const preferredLang = useSurveyPreferredLang() as string
+ const { rows, loading } = useUserGroupsList()
+
+ const columns: GridColDef[] = useMemo(
+ () => [
+ {
+ field: 'name',
+ headerName: i18n.t('usersView:userGroup.name'),
+ flex: 1,
+ valueGetter: (_value, row) => UserGroup.getName(row),
+ },
+ {
+ field: 'label',
+ headerName: i18n.t('usersView:userGroup.label'),
+ flex: 1,
+ valueGetter: (_value, row) => UserGroup.getLabel(preferredLang)(row),
+ },
+ {
+ field: 'qualifiers',
+ headerName: i18n.t('usersView:userGroup.qualifier_plural'),
+ flex: 1,
+ valueGetter: (_value, row) => UserGroup.getQualifiers(row).length,
+ },
+ {
+ field: 'membersCount',
+ headerName: i18n.t('usersView:userGroup.members'),
+ flex: 1,
+ },
+ ],
+ [i18n, preferredLang]
+ )
+
+ const onRowClick = (params: GridRowParams): void =>
+ navigate(`${appModuleUri(userModules.userGroup as AppModule)}${UserGroup.getUuid(params.row)}`)
+
+ if (loading) {
+ return
+ }
+
+ return (
+ UserGroup.getUuid(row) as string}
+ onRowClick={onRowClick}
+ />
+ )
+}
+
+export default UserGroupsList
diff --git a/webapp/views/App/views/Users/UserGroupsOverview/UserGroupsList/index.ts b/webapp/views/App/views/UserGroups/UserGroupsEditor/UserGroupsList/index.ts
similarity index 100%
rename from webapp/views/App/views/Users/UserGroupsOverview/UserGroupsList/index.ts
rename to webapp/views/App/views/UserGroups/UserGroupsEditor/UserGroupsList/index.ts
diff --git a/webapp/views/App/views/UserGroups/UserGroupsEditor/UserGroupsList/useUserGroupsList.ts b/webapp/views/App/views/UserGroups/UserGroupsEditor/UserGroupsList/useUserGroupsList.ts
new file mode 100644
index 0000000000..353792b12f
--- /dev/null
+++ b/webapp/views/App/views/UserGroups/UserGroupsEditor/UserGroupsList/useUserGroupsList.ts
@@ -0,0 +1,63 @@
+import { useEffect, useState } from 'react'
+
+import { UserGroup as UserGroupType } from '@openforis/arena-core'
+
+import * as UserGroup from '@core/user/userGroup/userGroup'
+
+import * as API from '@webapp/service/api'
+import { useSurveyId } from '@webapp/store/survey'
+
+export type UserGroupListRow = UserGroupType & { membersCount: number }
+
+interface UseUserGroupsListResult {
+ rows: UserGroupListRow[]
+ loading: boolean
+}
+
+// The user groups list endpoint doesn't return membersCount, so it's fetched separately per group,
+// following the useUserGroupsTable.ts precedent (group counts per survey are small).
+const fetchUserGroupsListRows = async (surveyId: string): Promise => {
+ const groups = await API.fetchUserGroups({ surveyId })
+
+ const membersCounts = await Promise.all(
+ groups.map((group) =>
+ API.fetchUserGroupMembers({ surveyId, groupUuid: UserGroup.getUuid(group) as string }).then(
+ (members) => members.length
+ )
+ )
+ )
+
+ return groups.map((group, index) => ({ ...group, membersCount: membersCounts[index] }))
+}
+
+/**
+ * Loads every user group defined in the current survey, together with its members count, for
+ * display in a plain groups list.
+ *
+ * @returns {UseUserGroupsListResult} The user groups and a loading flag.
+ */
+export const useUserGroupsList = (): UseUserGroupsListResult => {
+ // useSurveyId's untyped implementation makes TS infer its return type as `unknown`; cast to the
+ // `string` shape the API service functions declare, following the useUserGroupsTable.ts precedent.
+ const surveyId = useSurveyId() as string
+
+ const [rows, setRows] = useState([])
+ const [loading, setLoading] = useState(true)
+
+ useEffect(() => {
+ let ignore = false
+
+ fetchUserGroupsListRows(surveyId).then((data) => {
+ if (!ignore) {
+ setRows(data)
+ setLoading(false)
+ }
+ })
+
+ return () => {
+ ignore = true
+ }
+ }, [surveyId])
+
+ return { rows, loading }
+}
diff --git a/webapp/views/App/views/Users/UserGroupsOverview/UserGroupsSummary/UserCard.tsx b/webapp/views/App/views/UserGroups/UserGroupsEditor/UserGroupsOverview/UserCard.tsx
similarity index 94%
rename from webapp/views/App/views/Users/UserGroupsOverview/UserGroupsSummary/UserCard.tsx
rename to webapp/views/App/views/UserGroups/UserGroupsEditor/UserGroupsOverview/UserCard.tsx
index 05025e1c69..161e7c4776 100644
--- a/webapp/views/App/views/Users/UserGroupsOverview/UserGroupsSummary/UserCard.tsx
+++ b/webapp/views/App/views/UserGroups/UserGroupsEditor/UserGroupsOverview/UserCard.tsx
@@ -4,7 +4,7 @@ import * as User from '@core/user/user'
import ProfilePicture from '@webapp/components/profilePicture'
-import type { SurveyUserType } from './useUserGroupsSummary'
+import type { SurveyUserType } from './useUserGroupsOverview'
type Props = {
user: SurveyUserType
@@ -13,7 +13,7 @@ type Props = {
}
/**
- * A single card representing a survey user inside a UserGroupColumn of the UserGroupsSummary
+ * A single card representing a survey user inside a UserGroupColumn of the UserGroupsOverview
* Kanban board. Draggable cards (draggable=true) can be dragged between columns to assign,
* reassign or unassign the user's group.
*
diff --git a/webapp/views/App/views/Users/UserGroupsOverview/UserGroupsSummary/UserGroupColumn.tsx b/webapp/views/App/views/UserGroups/UserGroupsEditor/UserGroupsOverview/UserGroupColumn.tsx
similarity index 94%
rename from webapp/views/App/views/Users/UserGroupsOverview/UserGroupsSummary/UserGroupColumn.tsx
rename to webapp/views/App/views/UserGroups/UserGroupsEditor/UserGroupsOverview/UserGroupColumn.tsx
index 12f0f1831b..dec5aed1d9 100644
--- a/webapp/views/App/views/Users/UserGroupsOverview/UserGroupsSummary/UserGroupColumn.tsx
+++ b/webapp/views/App/views/UserGroups/UserGroupsEditor/UserGroupsOverview/UserGroupColumn.tsx
@@ -8,7 +8,7 @@ import * as UserGroup from '@core/user/userGroup/userGroup'
import { useSurveyPreferredLang } from '@webapp/store/survey'
import { useI18n } from '@webapp/store/system'
-import type { SurveyUserType } from './useUserGroupsSummary'
+import type { SurveyUserType } from './useUserGroupsOverview'
import UserCard from './UserCard'
type Props = {
@@ -21,7 +21,7 @@ type Props = {
}
/**
- * One column of the UserGroupsSummary Kanban board: either a user group or the "Unassigned"
+ * One column of the UserGroupsOverview Kanban board: either a user group or the "Unassigned"
* bucket (when group is null), listing the survey users currently assigned to it as cards.
*
* @param props0 - The component props.
diff --git a/webapp/views/App/views/Users/UserGroupsOverview/UserGroupsSummary/UserGroupsSummary.scss b/webapp/views/App/views/UserGroups/UserGroupsEditor/UserGroupsOverview/UserGroupsOverview.scss
similarity index 95%
rename from webapp/views/App/views/Users/UserGroupsOverview/UserGroupsSummary/UserGroupsSummary.scss
rename to webapp/views/App/views/UserGroups/UserGroupsEditor/UserGroupsOverview/UserGroupsOverview.scss
index 643adb847d..c97eb49595 100644
--- a/webapp/views/App/views/Users/UserGroupsOverview/UserGroupsSummary/UserGroupsSummary.scss
+++ b/webapp/views/App/views/UserGroups/UserGroupsEditor/UserGroupsOverview/UserGroupsOverview.scss
@@ -1,10 +1,11 @@
@use '~@webapp/style/vars' as *;
-.user-groups-summary {
+.user-groups-overview {
display: flex;
- align-items: flex-start;
+ align-items: stretch;
gap: 1rem;
overflow-x: auto;
+ overflow-y: hidden;
padding: 0.5rem 0 1rem;
}
@@ -16,6 +17,7 @@
background-color: $greyAppBg;
border: 1px solid $greyBorder;
border-radius: 4px;
+ min-height: 0;
&__header {
display: flex;
@@ -46,7 +48,6 @@
&__list {
flex: 1;
min-height: 3rem;
- max-height: 28rem;
overflow-y: auto;
margin: 0;
padding: 0.5rem;
diff --git a/webapp/views/App/views/Users/UserGroupsOverview/UserGroupsSummary/UserGroupsSummary.tsx b/webapp/views/App/views/UserGroups/UserGroupsEditor/UserGroupsOverview/UserGroupsOverview.tsx
similarity index 83%
rename from webapp/views/App/views/Users/UserGroupsOverview/UserGroupsSummary/UserGroupsSummary.tsx
rename to webapp/views/App/views/UserGroups/UserGroupsEditor/UserGroupsOverview/UserGroupsOverview.tsx
index d0d55f6745..129500b3f4 100644
--- a/webapp/views/App/views/Users/UserGroupsOverview/UserGroupsSummary/UserGroupsSummary.tsx
+++ b/webapp/views/App/views/UserGroups/UserGroupsEditor/UserGroupsOverview/UserGroupsOverview.tsx
@@ -1,4 +1,4 @@
-import './UserGroupsSummary.scss'
+import './UserGroupsOverview.scss'
import React, { useMemo } from 'react'
@@ -10,8 +10,8 @@ import * as UserGroup from '@core/user/userGroup/userGroup'
import { useAuthCanManageUserGroups } from '@webapp/store/user'
import { UNASSIGNED_GROUP_KEY } from './kanbanConstants'
-import type { SurveyUserType } from './useUserGroupsSummary'
-import { useUserGroupsSummary } from './useUserGroupsSummary'
+import type { SurveyUserType } from './useUserGroupsOverview'
+import { useUserGroupsOverview } from './useUserGroupsOverview'
import { useUserGroupsKanbanDnd } from './useUserGroupsKanbanDnd'
import UserGroupColumn from './UserGroupColumn'
@@ -26,11 +26,11 @@ interface ColumnData {
* a leading "Unassigned" column, each user shown as a draggable card. Dragging a card into a
* different column assigns, reassigns or unassigns that user.
*
- * @returns {React.ReactElement} - The UserGroupsSummary component.
+ * @returns {React.ReactElement} - The UserGroupsOverview component.
*/
-const UserGroupsSummary = (): React.ReactElement => {
+const UserGroupsOverview = (): React.ReactElement => {
const canManage = useAuthCanManageUserGroups()
- const { groups, users, groupUuidByUserUuid, onChangeUserGroup, pendingUserUuids } = useUserGroupsSummary()
+ const { groups, users, groupUuidByUserUuid, onChangeUserGroup, pendingUserUuids } = useUserGroupsOverview()
const columns: ColumnData[] = useMemo(() => {
const unassignedMembers = users.filter((user) => !groupUuidByUserUuid[User.getUuid(user) as string])
@@ -53,7 +53,7 @@ const UserGroupsSummary = (): React.ReactElement => {
})
return (
-
+
{columns.map((column) => (
{
)
}
-export default UserGroupsSummary
+export default UserGroupsOverview
diff --git a/webapp/views/App/views/UserGroups/UserGroupsEditor/UserGroupsOverview/index.ts b/webapp/views/App/views/UserGroups/UserGroupsEditor/UserGroupsOverview/index.ts
new file mode 100644
index 0000000000..51e516d051
--- /dev/null
+++ b/webapp/views/App/views/UserGroups/UserGroupsEditor/UserGroupsOverview/index.ts
@@ -0,0 +1 @@
+export { default } from './UserGroupsOverview'
diff --git a/webapp/views/App/views/Users/UserGroupsOverview/UserGroupsSummary/kanbanConstants.ts b/webapp/views/App/views/UserGroups/UserGroupsEditor/UserGroupsOverview/kanbanConstants.ts
similarity index 100%
rename from webapp/views/App/views/Users/UserGroupsOverview/UserGroupsSummary/kanbanConstants.ts
rename to webapp/views/App/views/UserGroups/UserGroupsEditor/UserGroupsOverview/kanbanConstants.ts
diff --git a/webapp/views/App/views/Users/UserGroupsOverview/UserGroupsSummary/useUserGroupsKanbanDnd.ts b/webapp/views/App/views/UserGroups/UserGroupsEditor/UserGroupsOverview/useUserGroupsKanbanDnd.ts
similarity index 98%
rename from webapp/views/App/views/Users/UserGroupsOverview/UserGroupsSummary/useUserGroupsKanbanDnd.ts
rename to webapp/views/App/views/UserGroups/UserGroupsEditor/UserGroupsOverview/useUserGroupsKanbanDnd.ts
index b2cdb3ab0e..ae0bfedea9 100644
--- a/webapp/views/App/views/Users/UserGroupsOverview/UserGroupsSummary/useUserGroupsKanbanDnd.ts
+++ b/webapp/views/App/views/UserGroups/UserGroupsEditor/UserGroupsOverview/useUserGroupsKanbanDnd.ts
@@ -16,7 +16,7 @@ interface UseUserGroupsKanbanDndResult {
}
/**
- * Wires up cross-column drag-and-drop for the UserGroupsSummary Kanban board, using
+ * Wires up cross-column drag-and-drop for the UserGroupsOverview Kanban board, using
* @shopify/draggable's Sortable (which natively supports multiple containers). Dropping a user
* card into a different column calls onChangeUserGroup with the target column's group uuid (or
* null for the Unassigned column). Dropping within the same column is a no-op, since group
diff --git a/webapp/views/App/views/Users/UserGroupsOverview/UserGroupsSummary/useUserGroupsSummary.ts b/webapp/views/App/views/UserGroups/UserGroupsEditor/UserGroupsOverview/useUserGroupsOverview.ts
similarity index 96%
rename from webapp/views/App/views/Users/UserGroupsOverview/UserGroupsSummary/useUserGroupsSummary.ts
rename to webapp/views/App/views/UserGroups/UserGroupsEditor/UserGroupsOverview/useUserGroupsOverview.ts
index d481df9526..64babaf210 100644
--- a/webapp/views/App/views/Users/UserGroupsOverview/UserGroupsSummary/useUserGroupsSummary.ts
+++ b/webapp/views/App/views/UserGroups/UserGroupsEditor/UserGroupsOverview/useUserGroupsOverview.ts
@@ -23,7 +23,7 @@ interface FetchedData {
groupUuidByUserUuid: Record
}
-interface UseUserGroupsSummaryResult {
+interface UseUserGroupsOverviewResult {
groups: UserGroupType[]
users: SurveyUserType[]
groupUuidByUserUuid: Record
@@ -38,10 +38,10 @@ interface UseUserGroupsSummaryResult {
* handler to move a user to a different group (or unassign them) by optimistically updating that
* map and then removing/adding group membership on the server.
*
- * @returns {UseUserGroupsSummaryResult} The groups, survey users, the userUuid -> groupUuid map,
+ * @returns {UseUserGroupsOverviewResult} The groups, survey users, the userUuid -> groupUuid map,
* and the handler to change a user's group.
*/
-export const useUserGroupsSummary = (): UseUserGroupsSummaryResult => {
+export const useUserGroupsOverview = (): UseUserGroupsOverviewResult => {
// NotificationActions dispatches a thunk (function), not a plain action, but the untyped store
// JS modules make useDispatch() infer the plain redux `Dispatch` type; type it
// explicitly as a thunk dispatch here, following the precedent in useEditUserGroup.ts /
@@ -131,7 +131,7 @@ export const useUserGroupsSummary = (): UseUserGroupsSummaryResult => {
// reload (groups + users + every group's members) on top of the two mutation requests. On failure,
// the mapping is rolled back to groupUuidOld so the card returns to its original column. Also
// marks the user pending for the duration of the request (cleared in `finally`, so it's cleared
- // on both success and failure): UserGroupsSummary uses pendingUserUuids to keep the card from
+ // on both success and failure): UserGroupsOverview uses pendingUserUuids to keep the card from
// being dragged again until this call settles, since a second concurrent call for the same user
// would race its remove/add requests against this one and could leave the two calls' optimistic
// updates/rollbacks stomping on each other.
diff --git a/webapp/views/App/views/Users/UserGroupsOverview/UserGroupsTable/UserGroupsTable.tsx b/webapp/views/App/views/UserGroups/UserGroupsEditor/UserGroupsTable/UserGroupsTable.tsx
similarity index 98%
rename from webapp/views/App/views/Users/UserGroupsOverview/UserGroupsTable/UserGroupsTable.tsx
rename to webapp/views/App/views/UserGroups/UserGroupsEditor/UserGroupsTable/UserGroupsTable.tsx
index 47db6c759f..f67946edc9 100644
--- a/webapp/views/App/views/Users/UserGroupsOverview/UserGroupsTable/UserGroupsTable.tsx
+++ b/webapp/views/App/views/UserGroups/UserGroupsEditor/UserGroupsTable/UserGroupsTable.tsx
@@ -46,7 +46,7 @@ const UserGroupsTable = (): React.ReactElement => {
return
}
- return
+ return
}
export default UserGroupsTable
diff --git a/webapp/views/App/views/Users/UserGroupsOverview/UserGroupsTable/index.ts b/webapp/views/App/views/UserGroups/UserGroupsEditor/UserGroupsTable/index.ts
similarity index 100%
rename from webapp/views/App/views/Users/UserGroupsOverview/UserGroupsTable/index.ts
rename to webapp/views/App/views/UserGroups/UserGroupsEditor/UserGroupsTable/index.ts
diff --git a/webapp/views/App/views/Users/UserGroupsOverview/UserGroupsTable/useUserGroupsTable.ts b/webapp/views/App/views/UserGroups/UserGroupsEditor/UserGroupsTable/useUserGroupsTable.ts
similarity index 99%
rename from webapp/views/App/views/Users/UserGroupsOverview/UserGroupsTable/useUserGroupsTable.ts
rename to webapp/views/App/views/UserGroups/UserGroupsEditor/UserGroupsTable/useUserGroupsTable.ts
index 3bbbb2de11..f631e74f71 100644
--- a/webapp/views/App/views/Users/UserGroupsOverview/UserGroupsTable/useUserGroupsTable.ts
+++ b/webapp/views/App/views/UserGroups/UserGroupsEditor/UserGroupsTable/useUserGroupsTable.ts
@@ -142,7 +142,7 @@ const fetchUserGroupsTableRows = async (params: {
*/
export const useUserGroupsTable = (): UseUserGroupsTableResult => {
// useSurveyId's untyped implementation makes TS infer its return type as `unknown`; cast to the
- // `string` shape the API service functions declare, following the useUserGroupsSummary.ts precedent.
+ // `string` shape the API service functions declare, following the useUserGroupsOverview.ts precedent.
const surveyId = useSurveyId() as string
const surveyInfo = useSurveyInfo()
const surveyUuid = Survey.getUuid(surveyInfo)
diff --git a/webapp/views/App/views/UserGroups/UserGroupsEditor/index.tsx b/webapp/views/App/views/UserGroups/UserGroupsEditor/index.tsx
new file mode 100644
index 0000000000..7a8a39b512
--- /dev/null
+++ b/webapp/views/App/views/UserGroups/UserGroupsEditor/index.tsx
@@ -0,0 +1 @@
+export { default } from './UserGroupsEditor'
diff --git a/webapp/views/App/views/UserGroups/index.js b/webapp/views/App/views/UserGroups/index.js
new file mode 100644
index 0000000000..7a8a39b512
--- /dev/null
+++ b/webapp/views/App/views/UserGroups/index.js
@@ -0,0 +1 @@
+export { default } from './UserGroupsEditor'
diff --git a/webapp/views/App/views/Users/UserGroupsOverview/UserGroupsList/Row.tsx b/webapp/views/App/views/Users/UserGroupsOverview/UserGroupsList/Row.tsx
deleted file mode 100644
index 996cac431b..0000000000
--- a/webapp/views/App/views/Users/UserGroupsOverview/UserGroupsList/Row.tsx
+++ /dev/null
@@ -1,43 +0,0 @@
-import React from 'react'
-
-import { UserGroup as UserGroupType } from '@openforis/arena-core'
-
-import * as UserGroup from '@core/user/userGroup/userGroup'
-
-import { useSurveyPreferredLang } from '@webapp/store/survey'
-
-/**
- * Minimal shape of a user group list item, as returned by the arena-server user groups list endpoint.
- * Note: `membersCount` is not currently returned by that endpoint (it only returns rows with `props`),
- * so it defaults to 0 below; this is a known, accepted limitation, out of scope to fix here.
- */
-type UserGroupRow = UserGroupType & {
- membersCount?: number
-}
-
-type Props = {
- row: UserGroupRow
-}
-
-/**
- * A single row of the User Groups list table, rendering name, label, qualifiers count and members count.
- *
- * @param props0 - The component props.
- * @param props0.row - The user group to render.
- * @returns {React.ReactElement} - The Row component.
- */
-const Row = (props: Props): React.ReactElement => {
- const { row: userGroup } = props
- const preferredLang = useSurveyPreferredLang()
-
- return (
- <>
-
{UserGroup.getName(userGroup)}
-
{UserGroup.getLabel(preferredLang)(userGroup)}
-
{UserGroup.getQualifiers(userGroup).length}
-
{userGroup.membersCount ?? 0}
- >
- )
-}
-
-export default Row
diff --git a/webapp/views/App/views/Users/UserGroupsOverview/UserGroupsList/RowHeader.tsx b/webapp/views/App/views/Users/UserGroupsOverview/UserGroupsList/RowHeader.tsx
deleted file mode 100644
index c33245b87e..0000000000
--- a/webapp/views/App/views/Users/UserGroupsOverview/UserGroupsList/RowHeader.tsx
+++ /dev/null
@@ -1,23 +0,0 @@
-import React from 'react'
-
-import { useI18n } from '@webapp/store/system'
-
-/**
- * Column titles for the User Groups list table.
- *
- * @returns {React.ReactElement} - The RowHeader component.
- */
-const RowHeader = (): React.ReactElement => {
- const i18n = useI18n()
-
- return (
- <>
-
{i18n.t('usersView:userGroup.name')}
-
{i18n.t('usersView:userGroup.label')}
-
{i18n.t('usersView:userGroup.qualifier_plural')}
-
{i18n.t('usersView:userGroup.members')}
- >
- )
-}
-
-export default RowHeader
diff --git a/webapp/views/App/views/Users/UserGroupsOverview/UserGroupsList/UserGroupsList.tsx b/webapp/views/App/views/Users/UserGroupsOverview/UserGroupsList/UserGroupsList.tsx
deleted file mode 100644
index 0d4182fd35..0000000000
--- a/webapp/views/App/views/Users/UserGroupsOverview/UserGroupsList/UserGroupsList.tsx
+++ /dev/null
@@ -1,45 +0,0 @@
-import './UserGroupsList.scss'
-
-import React from 'react'
-import { useNavigate } from 'react-router-dom'
-
-import * as UserGroup from '@core/user/userGroup/userGroup'
-import { appModuleUri, userModules } from '@webapp/app/appModules'
-import { useSurveyId } from '@webapp/store/survey'
-import Table from '@webapp/components/Table/Table'
-
-import RowHeader from './RowHeader'
-import Row from './Row'
-
-// appModules.js is a plain JS module without explicit types: TS infers appModuleUri's parameter shape
-// from its default value (appModules.home), which happens to include an `icon` field that userModules
-// entries don't have (and that appModuleUri never reads). Cast to the function's own inferred parameter
-// type rather than editing that shared, out-of-scope module.
-type AppModule = Parameters[0]
-
-/**
- * User Groups list table, listing the user groups defined in the current survey.
- *
- * @returns {React.ReactElement} - The UserGroupsList component.
- */
-const UserGroupsList = (): React.ReactElement => {
- const navigate = useNavigate()
- const surveyId = useSurveyId()
-
- const onRowClick = (userGroup: Record): void =>
- navigate(`${appModuleUri(userModules.userGroup as AppModule)}${UserGroup.getUuid(userGroup)}`)
-
- return (
-
- )
-}
-
-export default UserGroupsList
diff --git a/webapp/views/App/views/Users/UserGroupsOverview/UserGroupsOverview.scss b/webapp/views/App/views/Users/UserGroupsOverview/UserGroupsOverview.scss
deleted file mode 100644
index 8eb8d98ccb..0000000000
--- a/webapp/views/App/views/Users/UserGroupsOverview/UserGroupsOverview.scss
+++ /dev/null
@@ -1,13 +0,0 @@
-.user-groups-overview {
- &__bar {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 1rem;
- }
-
- &__tabs {
- display: flex;
- gap: 0.5rem;
- }
-}
diff --git a/webapp/views/App/views/Users/UserGroupsOverview/UserGroupsSummary/index.ts b/webapp/views/App/views/Users/UserGroupsOverview/UserGroupsSummary/index.ts
deleted file mode 100644
index 9eaf60c95e..0000000000
--- a/webapp/views/App/views/Users/UserGroupsOverview/UserGroupsSummary/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { default } from './UserGroupsSummary'
diff --git a/webapp/views/App/views/Users/UserGroupsOverview/index.tsx b/webapp/views/App/views/Users/UserGroupsOverview/index.tsx
deleted file mode 100644
index b323fd8d5b..0000000000
--- a/webapp/views/App/views/Users/UserGroupsOverview/index.tsx
+++ /dev/null
@@ -1,107 +0,0 @@
-import './UserGroupsOverview.scss'
-
-import React from 'react'
-import { useLocation, useNavigate } from 'react-router-dom'
-
-import { useI18n } from '@webapp/store/system'
-import { useAuthCanManageUserGroups } from '@webapp/store/user'
-import { ButtonIconAdd } from '@webapp/components'
-import { useQuery } from '@webapp/components/hooks'
-import { appModuleUri, userModules } from '@webapp/app/appModules'
-
-import UserGroupsList from './UserGroupsList'
-import UserGroupsSummary from './UserGroupsSummary'
-import UserGroupsTable from './UserGroupsTable'
-
-// appModules.js is a plain JS module without explicit types: TS infers appModuleUri's parameter shape
-// from its default value (appModules.home), which happens to include an `icon` field that userModules
-// entries don't have (and that appModuleUri never reads). Cast to the function's own inferred parameter
-// type rather than editing that shared, out-of-scope module.
-type AppModule = Parameters[0]
-
-const ViewMode = {
- list: 'list',
- overview: 'overview',
- table: 'table',
-} as const
-
-type ViewModeType = (typeof ViewMode)[keyof typeof ViewMode]
-
-const isViewMode = (value: unknown): value is ViewModeType =>
- value === ViewMode.list || value === ViewMode.overview || value === ViewMode.table
-
-/**
- * User Groups overview page for a survey: toggles between the groups list, a Kanban-style summary
- * of every survey user's group assignment, and a flat report table (one row per group-member pair).
- * The summary and table view modes are only shown to users who can manage user groups; other users
- * only ever see the groups list. The "New Group" button is shown in every view mode to users
- * allowed to manage groups. The active view mode is kept in the URL's `view` query param (replacing
- * history rather than pushing) so that the "Back" button on the create/edit group page - a plain
- * `navigate(-1)` - lands back on whichever tab the user came from.
- *
- * @returns {React.ReactElement} - The UserGroupsOverview component.
- */
-const UserGroupsOverview = (): React.ReactElement => {
- const i18n = useI18n()
- const navigate = useNavigate()
- const location = useLocation()
- const canManage = useAuthCanManageUserGroups()
- // useQuery's untyped implementation makes TS infer its return type as `{}`; cast to the
- // `Record` shape its `URLSearchParams`-driven implementation actually returns.
- const query = useQuery() as Record
- const viewMode = isViewMode(query.view) ? query.view : ViewMode.list
-
- const effectiveViewMode = canManage ? viewMode : ViewMode.list
-
- const setViewMode = (mode: ViewModeType): void => {
- const search = mode === ViewMode.list ? '' : `?view=${mode}`
- navigate({ pathname: location.pathname, search }, { replace: true })
- }
-
- return (
-