diff --git a/core/i18n/resources/en/usersView.js b/core/i18n/resources/en/usersView.js index a17548d6f1..ba711c7de0 100644 --- a/core/i18n/resources/en/usersView.js +++ b/core/i18n/resources/en/usersView.js @@ -37,7 +37,6 @@ Copy the reset password link to the clipboard?`, Press Go to surveys to open the surveys list.`, userRoleUpdatedRefreshRequired: `Your role in this survey has been updated. $t(common.pressRefreshToReloadPage)`, - userGroups: 'User Groups', userGroup: { new: 'New Group', edit: 'Edit Group', @@ -52,8 +51,11 @@ $t(common.pressRefreshToReloadPage)`, addMember: 'Add member', noMembers: 'No members yet', memberAlreadyInGroup: 'Currently in "{{groupName}}"', - overview: 'Overview', - table: 'Table', + tabs: { + groups: 'User Groups', + assignments: 'Group Assignments', + report: 'Members Report', + }, memberName: 'Member Name', memberEmail: 'Member Email', memberRole: 'Survey Role', diff --git a/core/i18n/resources/es/usersView.js b/core/i18n/resources/es/usersView.js index 5a3d22e127..dfeac020e0 100644 --- a/core/i18n/resources/es/usersView.js +++ b/core/i18n/resources/es/usersView.js @@ -34,7 +34,6 @@ export default { Pulsa Ir a encuestas para abrir la lista de encuestas.`, userRoleUpdatedRefreshRequired: `Tu rol en esta encuesta se ha actualizado. $t(common.pressRefreshToReloadPage)`, - userGroups: 'Grupos de usuarios', userGroup: { new: 'Nuevo grupo', edit: 'Editar grupo', @@ -49,8 +48,11 @@ $t(common.pressRefreshToReloadPage)`, addMember: 'Añadir miembro', noMembers: 'Aún no hay miembros', memberAlreadyInGroup: 'Actualmente en "{{groupName}}"', - overview: 'Resumen', - table: 'Tabla', + tabs: { + groups: 'Grupos de usuarios', + assignments: 'Asignación de grupos', + report: 'Informe de miembros', + }, memberName: 'Nombre del miembro', memberEmail: 'Correo electrónico del miembro', memberRole: 'Rol en la encuesta', diff --git a/core/i18n/resources/fr/usersView.js b/core/i18n/resources/fr/usersView.js index 3ff55f9b62..99f2354ae6 100644 --- a/core/i18n/resources/fr/usersView.js +++ b/core/i18n/resources/fr/usersView.js @@ -38,7 +38,6 @@ Copier le lien de réinitialisation du mot de passe dans le presse-papiers ?`, Appuyez sur Aller aux formulaires pour ouvrir la liste des formulaires.`, userRoleUpdatedRefreshRequired: `Votre rôle dans ce formulaire a été mis à jour. $t(common.pressRefreshToReloadPage)`, - userGroups: "Groupes d'utilisateurs", userGroup: { new: 'Nouveau groupe', edit: 'Modifier le groupe', @@ -53,8 +52,11 @@ $t(common.pressRefreshToReloadPage)`, addMember: 'Ajouter un membre', noMembers: "Aucun membre pour l'instant", memberAlreadyInGroup: 'Actuellement dans "{{groupName}}"', - overview: 'Aperçu', - table: 'Tableau', + tabs: { + groups: "Groupes d'utilisateurs", + assignments: 'Attribution des groupes', + report: 'Rapport des membres', + }, memberName: 'Nom du membre', memberEmail: 'E-mail du membre', memberRole: 'Rôle dans le formulaire', diff --git a/core/i18n/resources/mn/usersView.js b/core/i18n/resources/mn/usersView.js index 29a2ac6409..e78a853a65 100644 --- a/core/i18n/resources/mn/usersView.js +++ b/core/i18n/resources/mn/usersView.js @@ -37,7 +37,6 @@ export default { Судалгааны жагсаалтыг нээхийн тулд Судалгааны жагсаалт руу очих товчийг дарна уу.`, userRoleUpdatedRefreshRequired: `Таны энэ судалгаан дахь үүрэг шинэчлэгдлээ. $t(common.pressRefreshToReloadPage)`, - userGroups: 'Хэрэглэгчийн бүлгүүд', userGroup: { new: 'Шинэ бүлэг', edit: 'Бүлэг засах', @@ -52,8 +51,11 @@ $t(common.pressRefreshToReloadPage)`, addMember: 'Гишүүн нэмэх', noMembers: 'Гишүүн байхгүй байна', memberAlreadyInGroup: '"{{groupName}}" бүлэгт байна', - overview: 'Тойм', - table: 'Хүснэгт', + tabs: { + groups: 'Хэрэглэгчийн бүлгүүд', + assignments: 'Гишүүдийн хуваарилалт', + report: 'Гишүүдийн тайлан', + }, memberName: 'Гишүүний нэр', memberEmail: 'Гишүүний имэйл', memberRole: 'Судалгаан дахь үүрэг', diff --git a/core/i18n/resources/pt/usersView.js b/core/i18n/resources/pt/usersView.js index 2e7eed52e5..c50ee51b47 100644 --- a/core/i18n/resources/pt/usersView.js +++ b/core/i18n/resources/pt/usersView.js @@ -37,7 +37,6 @@ Copiar o link de redefinição de senha para a área de transferência?`, Pressione Ir para inventários para abrir a lista de inventários.`, userRoleUpdatedRefreshRequired: `Sua função neste inventário foi atualizada. $t(common.pressRefreshToReloadPage)`, - userGroups: 'Grupos de usuários', userGroup: { new: 'Novo grupo', edit: 'Editar grupo', @@ -52,8 +51,11 @@ $t(common.pressRefreshToReloadPage)`, addMember: 'Adicionar membro', noMembers: 'Ainda não há membros', memberAlreadyInGroup: 'Atualmente em "{{groupName}}"', - overview: 'Visão geral', - table: 'Tabela', + tabs: { + groups: 'Grupos de usuários', + assignments: 'Atribuição de grupos', + report: 'Relatório de membros', + }, memberName: 'Nome do membro', memberEmail: 'Email do membro', memberRole: 'Função no inventário', diff --git a/core/i18n/resources/ru/usersView.js b/core/i18n/resources/ru/usersView.js index 891f15abe1..4be512a235 100644 --- a/core/i18n/resources/ru/usersView.js +++ b/core/i18n/resources/ru/usersView.js @@ -38,7 +38,6 @@ export default { Нажмите «Перейти к опросам», чтобы открыть список опросов.`, userRoleUpdatedRefreshRequired: `Ваша роль в этом опросе была обновлена. $t(common.pressRefreshToReloadPage)`, - userGroups: 'Группы пользователей', userGroup: { new: 'Новая группа', edit: 'Редактировать группу', @@ -53,8 +52,11 @@ $t(common.pressRefreshToReloadPage)`, addMember: 'Добавить участника', noMembers: 'Пока нет участников', memberAlreadyInGroup: 'В настоящее время в "{{groupName}}"', - overview: 'Обзор', - table: 'Таблица', + tabs: { + groups: 'Группы пользователей', + assignments: 'Распределение по группам', + report: 'Отчёт по участникам', + }, memberName: 'Имя участника', memberEmail: 'Email участника', memberRole: 'Роль в опросе', diff --git a/webapp/components/DataGrid/DataGrid.tsx b/webapp/components/DataGrid/DataGrid.tsx index 7db781c53a..020a9ec20b 100644 --- a/webapp/components/DataGrid/DataGrid.tsx +++ b/webapp/components/DataGrid/DataGrid.tsx @@ -29,6 +29,7 @@ type Props = { getRowId?: GridRowIdGetter hideFooterPagination?: boolean initialState?: GridInitialState + onRowClick?: DataGridProps['onRowClick'] onRowDoubleClick?: DataGridProps['onRowDoubleClick'] rows: GridRowsProp } @@ -58,6 +59,7 @@ const DataGrid = (props: Props) => { getRowId, hideFooterPagination = false, initialState, + onRowClick, onRowDoubleClick, rows, } = props @@ -80,6 +82,7 @@ const DataGrid = (props: Props) => { getRowId={getRowId} hideFooterPagination={hideFooterPagination} initialState={initialState} + onRowClick={onRowClick} onRowDoubleClick={onRowDoubleClick} rows={rows} slots={allowExportToCsv ? { footer: FooterWithExport({ exportFileName }) } : undefined} diff --git a/webapp/components/Tabs/Tabs.js b/webapp/components/Tabs/Tabs.js index 5e62357729..e70669133b 100644 --- a/webapp/components/Tabs/Tabs.js +++ b/webapp/components/Tabs/Tabs.js @@ -9,26 +9,44 @@ import MuiTab from '@mui/material/Tab' import { useI18n } from '@webapp/store/system' export const Tabs = (props) => { - const { items, orientation = 'horizontal' } = props + const { fullWidth = false, items, onChange: onChangeProp, orientation = 'horizontal', selectedItemKey } = props - const [selectedIndex, setSelectedIndex] = useState(0) + const [selectedIndexState, setSelectedIndexState] = useState(0) const i18n = useI18n() + // Controlled mode (selectedItemKey given by the caller) is opt-in, so existing uncontrolled + // callers like Dashboard.js keep managing the selected tab internally. + const controlled = selectedItemKey !== undefined && selectedItemKey !== null + const selectedIndex = controlled + ? Math.max( + items.findIndex((item) => item.key === selectedItemKey), + 0 + ) + : selectedIndexState + const onChange = (_event, newIndex) => { - setSelectedIndex(newIndex) + if (!controlled) { + setSelectedIndexState(newIndex) + } + onChangeProp?.(items[newIndex].key) } const selectedItem = items[selectedIndex] return (
- + {items.map((item) => ( ))} - {selectedItem && ( + {selectedItem?.renderContent && (
{selectedItem.renderContent()}
@@ -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 ( +
+
+ + {canManage && ( + navigate(appModuleUri(userModules.userGroupNew as AppModule))} + variant="contained" + /> + )} +
+
+ ) +} + +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 ( -
-
-
- - {canManage && ( - <> - - - - )} -
- {canManage && ( - navigate(appModuleUri(userModules.userGroupNew as AppModule))} - variant="contained" - /> - )} -
- {effectiveViewMode === ViewMode.overview && } - {effectiveViewMode === ViewMode.table && } - {effectiveViewMode === ViewMode.list && } -
- ) -} - -export default UserGroupsOverview diff --git a/webapp/views/App/views/Users/Users.js b/webapp/views/App/views/Users/Users.js index 8aaef38cce..3bd228c7d6 100644 --- a/webapp/views/App/views/Users/Users.js +++ b/webapp/views/App/views/Users/Users.js @@ -19,8 +19,8 @@ import UserEdit from './UserEdit' import { UsersAccessRequest } from './UsersAccessRequest' import UserPasswordChange from './UserPasswordChange' import User2FADevice from './User2FADevice' -import UserGroupsOverview from './UserGroupsOverview' -import UserGroupEdit from './UserGroupEdit' +import UserGroupsEditor from '../UserGroups' +import UserGroupEdit from '../UserGroups/UserGroupEdit' const Users = () => { const user = useUser() @@ -55,7 +55,7 @@ const Users = () => { path: userModules.userInvite.path, }, { - component: UserGroupsOverview, + component: UserGroupsEditor, path: userModules.userGroups.path, }, {