Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions core/i18n/resources/en/usersView.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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',
Expand Down
8 changes: 5 additions & 3 deletions core/i18n/resources/es/usersView.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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',
Expand Down
8 changes: 5 additions & 3 deletions core/i18n/resources/fr/usersView.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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',
Expand Down
8 changes: 5 additions & 3 deletions core/i18n/resources/mn/usersView.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ export default {
Судалгааны жагсаалтыг нээхийн тулд Судалгааны жагсаалт руу очих товчийг дарна уу.`,
userRoleUpdatedRefreshRequired: `Таны энэ судалгаан дахь үүрэг шинэчлэгдлээ.
$t(common.pressRefreshToReloadPage)`,
userGroups: 'Хэрэглэгчийн бүлгүүд',
userGroup: {
new: 'Шинэ бүлэг',
edit: 'Бүлэг засах',
Expand All @@ -52,8 +51,11 @@ $t(common.pressRefreshToReloadPage)`,
addMember: 'Гишүүн нэмэх',
noMembers: 'Гишүүн байхгүй байна',
memberAlreadyInGroup: '"{{groupName}}" бүлэгт байна',
overview: 'Тойм',
table: 'Хүснэгт',
tabs: {
groups: 'Хэрэглэгчийн бүлгүүд',
assignments: 'Гишүүдийн хуваарилалт',
report: 'Гишүүдийн тайлан',
},
memberName: 'Гишүүний нэр',
memberEmail: 'Гишүүний имэйл',
memberRole: 'Судалгаан дахь үүрэг',
Expand Down
8 changes: 5 additions & 3 deletions core/i18n/resources/pt/usersView.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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',
Expand Down
8 changes: 5 additions & 3 deletions core/i18n/resources/ru/usersView.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ export default {
Нажмите «Перейти к опросам», чтобы открыть список опросов.`,
userRoleUpdatedRefreshRequired: `Ваша роль в этом опросе была обновлена.
$t(common.pressRefreshToReloadPage)`,
userGroups: 'Группы пользователей',
userGroup: {
new: 'Новая группа',
edit: 'Редактировать группу',
Expand All @@ -53,8 +52,11 @@ $t(common.pressRefreshToReloadPage)`,
addMember: 'Добавить участника',
noMembers: 'Пока нет участников',
memberAlreadyInGroup: 'В настоящее время в "{{groupName}}"',
overview: 'Обзор',
table: 'Таблица',
tabs: {
groups: 'Группы пользователей',
assignments: 'Распределение по группам',
report: 'Отчёт по участникам',
},
memberName: 'Имя участника',
memberEmail: 'Email участника',
memberRole: 'Роль в опросе',
Expand Down
3 changes: 3 additions & 0 deletions webapp/components/DataGrid/DataGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ type Props = {
getRowId?: GridRowIdGetter
hideFooterPagination?: boolean
initialState?: GridInitialState
onRowClick?: DataGridProps['onRowClick']
onRowDoubleClick?: DataGridProps['onRowDoubleClick']
rows: GridRowsProp
}
Expand Down Expand Up @@ -58,6 +59,7 @@ const DataGrid = (props: Props) => {
getRowId,
hideFooterPagination = false,
initialState,
onRowClick,
onRowDoubleClick,
rows,
} = props
Expand All @@ -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}
Expand Down
31 changes: 26 additions & 5 deletions webapp/components/Tabs/Tabs.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className={classNames('tabs-wrapper', { vertical: orientation === 'vertical' })}>
<MuiTabs onChange={onChange} orientation={orientation} value={selectedIndex}>
<MuiTabs
onChange={onChange}
orientation={orientation}
value={selectedIndex}
variant={fullWidth ? 'fullWidth' : 'standard'}
>
{items.map((item) => (
<MuiTab key={item.key} label={i18n.t(item.label)} />
))}
</MuiTabs>
{selectedItem && (
{selectedItem?.renderContent && (
<div key={selectedItem.key} className="tab-content">
{selectedItem.renderContent()}
</div>
Expand All @@ -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,
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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%;
}
}
}
Original file line number Diff line number Diff line change
@@ -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<typeof appModuleUri>[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<string, string>` shape its `URLSearchParams`-driven implementation actually returns.
const query = useQuery() as Record<string, string>
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: () => <UserGroupsList /> },
...(canManage
? [
{
key: ViewMode.assignments,
label: 'usersView:userGroup.tabs.assignments',
renderContent: () => <UserGroupsOverview />,
},
{ key: ViewMode.report, label: 'usersView:userGroup.tabs.report', renderContent: () => <UserGroupsTable /> },
]
: []),
]

return (
<div className="user-groups-editor">
<div className="user-groups-editor__bar">
<Tabs fullWidth items={tabItems} selectedItemKey={effectiveViewMode} onChange={setViewMode} />
{canManage && (
<ButtonIconAdd
className="btn-add user-groups-editor__add"
showLabel
label="usersView:userGroup.new"
onClick={() => navigate(appModuleUri(userModules.userGroupNew as AppModule))}
variant="contained"
/>
)}
</div>
</div>
)
}

export default UserGroupsEditor
Loading
Loading