From a901ec80492d1427185c05914e23e3226229b282 Mon Sep 17 00:00:00 2001 From: Oliver Dudgeon <22367286+OliverDudgeon@users.noreply.github.com> Date: Thu, 4 Apr 2024 11:50:44 +0100 Subject: [PATCH] feat(unit-user-usage): add initial implementation --- components/Chips.tsx | 4 +- components/DataTable/DataTable.tsx | 47 ++-- components/projects/CreateProjectForm.tsx | 7 +- .../EditProjectButton/EditProjectButton.tsx | 62 ++--- .../EditProjectButton/EditProjectModal.tsx | 64 +++++ .../ProjectAdministrators.tsx | 34 ++- .../EditProjectButton/ProjectEditors.tsx | 30 +- .../EditProjectButton/ProjectObservers.tsx | 30 +- components/usage/UnitUserUsage.tsx | 102 +++++++ components/usage/UserUsageByProjectTable.tsx | 149 ++++++++++ components/usage/UserUsageTable.tsx | 257 ++++++++++++++++++ components/userContext/SelectUnit.tsx | 47 +++- .../DatasetSchemaViewModal.tsx | 2 +- features/DatasetsTable/DatasetsTable.tsx | 2 +- .../ProjectActions/ProjectActions.tsx | 19 +- features/ProjectStats/ProjectStatsSection.tsx | 7 +- features/ProjectTable/ProjectTable.tsx | 2 +- features/usage/UnitUsageView.tsx | 21 ++ .../UserSettingsContent.tsx | 2 +- pages/unit/[unitId]/user-usage.tsx | 28 ++ pnpm-lock.yaml | 8 +- types/nextjs-routes.d.ts | 1 + 22 files changed, 801 insertions(+), 124 deletions(-) create mode 100644 components/projects/EditProjectButton/EditProjectModal.tsx create mode 100644 components/usage/UnitUserUsage.tsx create mode 100644 components/usage/UserUsageByProjectTable.tsx create mode 100644 components/usage/UserUsageTable.tsx create mode 100644 features/usage/UnitUsageView.tsx create mode 100644 pages/unit/[unitId]/user-usage.tsx diff --git a/components/Chips.tsx b/components/Chips.tsx index 18662522c..1e6b53787 100644 --- a/components/Chips.tsx +++ b/components/Chips.tsx @@ -15,8 +15,6 @@ export const Chips = styled("div", { shouldForwardProp: (prop) => prop !== "spac display: "flex", alignItems: "center", flexWrap: "wrap", - "& > *": { - margin: theme.spacing(spacing), - }, + gap: theme.spacing(spacing), }), ); diff --git a/components/DataTable/DataTable.tsx b/components/DataTable/DataTable.tsx index 07fd3db80..7307c9daf 100644 --- a/components/DataTable/DataTable.tsx +++ b/components/DataTable/DataTable.tsx @@ -70,7 +70,7 @@ export interface DataTableProps> { /** * Child element of the toolbar in the table header */ - ToolbarChild?: ReactNode; + toolbarContent?: ReactNode; /** * Toolbar with actions which sits beneath the table header toolbar with search. */ @@ -150,7 +150,7 @@ export const DataTable = >(props: DataTableProp columns, data, ToolbarActionChild, - ToolbarChild, + toolbarContent: ToolbarChild, initialSelection, onSelection, subRowsEnabled, @@ -296,29 +296,32 @@ export const DataTable = >(props: DataTableProp {headerGroup.headers.map((header) => ( - - {flexRender(header.column.columnDef.header, header.getContext())} - {header.column.getCanSort() ? ( - - ) : null} - {header.column.getCanFilter() ? ( -
- {/* TODO: debounce this field */} - header.column.setFilterValue(event.target.value)} + {header.isPlaceholder ? null : ( + + {flexRender(header.column.columnDef.header, header.getContext())} + {header.column.getCanSort() ? ( + -
- ) : null} -
+ ) : null} + {header.column.getCanFilter() ? ( +
+ {/* TODO: debounce this field */} + header.column.setFilterValue(event.target.value)} + /> +
+ ) : null} + + )}
))} diff --git a/components/projects/CreateProjectForm.tsx b/components/projects/CreateProjectForm.tsx index 0ddf4903b..187d71782 100644 --- a/components/projects/CreateProjectForm.tsx +++ b/components/projects/CreateProjectForm.tsx @@ -11,6 +11,7 @@ import { useGetProductTypes, } from "@squonk/account-server-client/product"; import type { DmError } from "@squonk/data-manager-client"; +import { getGetUserInventoryQueryKey } from "@squonk/data-manager-client/inventory"; import { getGetProjectsQueryKey, useCreateProject } from "@squonk/data-manager-client/project"; import { @@ -104,8 +105,12 @@ export const CreateProjectForm = ({ modal, unitId, product }: CreateProjectFormP queryClient.invalidateQueries({ queryKey: getGetProjectsQueryKey() }); queryClient.invalidateQueries({ queryKey: getGetProductsQueryKey() }); - typeof unitId === "string" && + if (typeof unitId === "string") { queryClient.invalidateQueries({ queryKey: getGetProductsForUnitQueryKey(unitId) }); + queryClient.invalidateQueries({ + queryKey: getGetUserInventoryQueryKey({ unit_id: unitId }), + }); + } setCurrentProjectId(project_id); } catch (error) { diff --git a/components/projects/EditProjectButton/EditProjectButton.tsx b/components/projects/EditProjectButton/EditProjectButton.tsx index c16b65f28..854a503dc 100644 --- a/components/projects/EditProjectButton/EditProjectButton.tsx +++ b/components/projects/EditProjectButton/EditProjectButton.tsx @@ -1,59 +1,43 @@ +import type { ReactNode } from "react"; import { useState } from "react"; -import type { ProjectDetail } from "@squonk/data-manager-client"; +import { useGetProject } from "@squonk/data-manager-client/project"; -import { Edit as EditIcon } from "@mui/icons-material"; -import { Box, IconButton, Tooltip, Typography } from "@mui/material"; +import { EditProjectModal } from "./EditProjectModal"; -import { ModalWrapper } from "../../modals/ModalWrapper"; -import { PrivateProjectToggle } from "./PrivateProjectToggle"; -import { ProjectAdministrators } from "./ProjectAdministrators"; -import { ProjectEditors } from "./ProjectEditors"; -import { ProjectObservers } from "./ProjectObservers"; +interface ChildProps { + openDialog: () => void; + open: boolean; +} export interface EditProjectButtonProps { /** - * Project to be edited. + * ID of project to be edited. + */ + projectId: string; + /** + * child render prop */ - project: ProjectDetail; + children: (props: ChildProps) => ReactNode; } /** * Button controlling a modal with options to edit the project editors */ -export const EditProjectButton = ({ project }: EditProjectButtonProps) => { +export const EditProjectButton = ({ projectId, children }: EditProjectButtonProps) => { const [open, setOpen] = useState(false); + const { data: project } = useGetProject(projectId); + + if (!project) { + return null; + } + return ( <> - - - setOpen(!open)}> - - - - - - setOpen(false)} - > - - {project.name} - - - - - - - - - - + {children({ openDialog: () => setOpen(true), open })} + + setOpen(false)} /> ); }; diff --git a/components/projects/EditProjectButton/EditProjectModal.tsx b/components/projects/EditProjectButton/EditProjectModal.tsx new file mode 100644 index 000000000..f441be238 --- /dev/null +++ b/components/projects/EditProjectButton/EditProjectModal.tsx @@ -0,0 +1,64 @@ +import { useGetProject } from "@squonk/data-manager-client/project"; + +import { Box, Typography } from "@mui/material"; + +import { ModalWrapper } from "../../modals/ModalWrapper"; +import { PrivateProjectToggle } from "./PrivateProjectToggle"; +import { ProjectAdministrators } from "./ProjectAdministrators"; +import { ProjectEditors } from "./ProjectEditors"; +import { ProjectObservers } from "./ProjectObservers"; + +export interface EditProjectModalProps { + projectId: string; + open: boolean; + onClose: () => void; + onMemberChange?: () => Promise; +} + +export const EditProjectModal = ({ + open, + projectId, + onClose, + onMemberChange, +}: EditProjectModalProps) => { + const { data: project } = useGetProject(projectId); + + if (project === undefined) { + return null; + } + + return ( + + + {project.name} + + + + + + + + + + + ); +}; diff --git a/components/projects/EditProjectButton/ProjectAdministrators.tsx b/components/projects/EditProjectButton/ProjectAdministrators.tsx index d39daab5b..06210a3f8 100644 --- a/components/projects/EditProjectButton/ProjectAdministrators.tsx +++ b/components/projects/EditProjectButton/ProjectAdministrators.tsx @@ -15,13 +15,25 @@ export interface ProjectAdministratorsProps { /** * Project to be edited. */ - project: ProjectDetail; + projectId: ProjectDetail["project_id"]; + /** + * administrators + */ + administrators: ProjectDetail["administrators"]; + /** + * onChange function to be called after the project administrators have been updated + */ + onChange?: () => Promise; } /** * MuiAutocomplete to manage the current administrators of the selected project */ -export const ProjectAdministrators = ({ project }: ProjectAdministratorsProps) => { +export const ProjectAdministrators = ({ + projectId, + administrators, + onChange, +}: ProjectAdministratorsProps) => { const { isLoading: isProjectsLoading } = useGetProjects(); const { mutateAsync: addAdministrator, isPending: isAdding } = useAddAdministratorToProject(); @@ -31,17 +43,19 @@ export const ProjectAdministrators = ({ project }: ProjectAdministratorsProps) = return ( addAdministrator({ projectId: project.project_id, userId })} + addMember={(userId) => addAdministrator({ projectId, userId })} isLoading={isAdding || isRemoving || isProjectsLoading} - memberList={project.administrators} - removeMember={(userId) => removeAdministrator({ projectId: project.project_id, userId })} + memberList={administrators} + removeMember={(userId) => removeAdministrator({ projectId, userId })} title="Administrators" - onSettled={() => - Promise.all([ - queryClient.invalidateQueries({ queryKey: getGetProjectQueryKey(project.project_id) }), + onSettled={() => { + const promises = [ + queryClient.invalidateQueries({ queryKey: getGetProjectQueryKey(projectId) }), queryClient.invalidateQueries({ queryKey: getGetProjectsQueryKey() }), - ]) - } + ]; + onChange && promises.push(onChange()); + return Promise.all(promises); + }} /> ); }; diff --git a/components/projects/EditProjectButton/ProjectEditors.tsx b/components/projects/EditProjectButton/ProjectEditors.tsx index b33af5605..97e6b474b 100644 --- a/components/projects/EditProjectButton/ProjectEditors.tsx +++ b/components/projects/EditProjectButton/ProjectEditors.tsx @@ -15,13 +15,21 @@ export interface ProjectEditorsProps { /** * Project to be edited. */ - project: ProjectDetail; + projectId: ProjectDetail["project_id"]; + /** + * editors + */ + editors: ProjectDetail["editors"]; + /** + * onChange function to be called after the project editors have been updated + */ + onChange?: () => Promise; } /** * MuiAutocomplete to manage the current editors of the selected project */ -export const ProjectEditors = ({ project }: ProjectEditorsProps) => { +export const ProjectEditors = ({ projectId, editors, onChange }: ProjectEditorsProps) => { const { isLoading: isProjectsLoading } = useGetProjects(); const { mutateAsync: addEditor, isPending: isAdding } = useAddEditorToProject(); @@ -30,17 +38,19 @@ export const ProjectEditors = ({ project }: ProjectEditorsProps) => { return ( addEditor({ projectId: project.project_id, userId })} + addMember={(userId) => addEditor({ projectId, userId })} isLoading={isAdding || isRemoving || isProjectsLoading} - memberList={project.editors} - removeMember={(userId) => removeEditor({ projectId: project.project_id, userId })} + memberList={editors} + removeMember={(userId) => removeEditor({ projectId, userId })} title="Editors" - onSettled={() => - Promise.all([ - queryClient.invalidateQueries({ queryKey: getGetProjectQueryKey(project.project_id) }), + onSettled={() => { + const promises = [ + queryClient.invalidateQueries({ queryKey: getGetProjectQueryKey(projectId) }), queryClient.invalidateQueries({ queryKey: getGetProjectsQueryKey() }), - ]) - } + ]; + onChange && promises.push(onChange()); + return Promise.all(promises); + }} /> ); }; diff --git a/components/projects/EditProjectButton/ProjectObservers.tsx b/components/projects/EditProjectButton/ProjectObservers.tsx index 9c1253ebc..64170b87c 100644 --- a/components/projects/EditProjectButton/ProjectObservers.tsx +++ b/components/projects/EditProjectButton/ProjectObservers.tsx @@ -15,13 +15,21 @@ export interface ProjectObserversProps { /** * Project to be edited. */ - project: ProjectDetail; + projectId: ProjectDetail["project_id"]; + /** + * observers + */ + observers: ProjectDetail["observers"]; + /** + * onChange function to be called after the project observers have been updated + */ + onChange?: () => Promise; } /** * Selector component to manage observers of a project. */ -export const ProjectObservers = ({ project }: ProjectObserversProps) => { +export const ProjectObservers = ({ projectId, observers, onChange }: ProjectObserversProps) => { const { isLoading: isProjectsLoading } = useGetProjects(); const { mutateAsync: addObserver, isPending: isAdding } = useAddObserverToProject(); @@ -30,17 +38,19 @@ export const ProjectObservers = ({ project }: ProjectObserversProps) => { return ( addObserver({ projectId: project.project_id, userId })} + addMember={(userId) => addObserver({ projectId, userId })} isLoading={isAdding || isRemoving || isProjectsLoading} - memberList={project.observers} - removeMember={(userId) => removeObserver({ projectId: project.project_id, userId })} + memberList={observers} + removeMember={(userId) => removeObserver({ projectId, userId })} title="Observers" - onSettled={() => - Promise.all([ - queryClient.invalidateQueries({ queryKey: getGetProjectQueryKey(project.project_id) }), + onSettled={() => { + const promises = [ + queryClient.invalidateQueries({ queryKey: getGetProjectQueryKey(projectId) }), queryClient.invalidateQueries({ queryKey: getGetProjectsQueryKey() }), - ]) - } + ]; + onChange && promises.push(onChange()); + return Promise.all(promises); + }} /> ); }; diff --git a/components/usage/UnitUserUsage.tsx b/components/usage/UnitUserUsage.tsx new file mode 100644 index 000000000..5eb4081d6 --- /dev/null +++ b/components/usage/UnitUserUsage.tsx @@ -0,0 +1,102 @@ +import { useGetUnit } from "@squonk/account-server-client/unit"; +import { useGetOrganisationUnitUsers } from "@squonk/account-server-client/user"; +import { + getGetUserInventoryQueryKey, + useGetUserInventory, +} from "@squonk/data-manager-client/inventory"; + +import dayjs from "dayjs"; +import utc from "dayjs/plugin/utc"; + +dayjs.extend(utc); + +import { useCallback, useState } from "react"; + +import { + Box, + CircularProgress, + Container, + FormControlLabel, + Switch, + Typography, +} from "@mui/material"; +import { useQueryClient } from "@tanstack/react-query"; +import dynamic from "next/dynamic"; + +import { CenterLoader } from "../CenterLoader"; +import type { UserUsageByProjectTableProps } from "./UserUsageByProjectTable"; +import { UserUsageTable } from "./UserUsageTable"; + +const UserUsageByProjectTable = dynamic( + () => import("./UserUsageByProjectTable").then((mod) => mod.UserUsageByProjectTable), + { loading: () => }, +); + +export interface UnitUserUsageProps { + unitId: string; +} + +export const UnitUserUsage = ({ unitId }: UnitUserUsageProps) => { + const queryClient = useQueryClient(); + + const invalidateQueries = useCallback(async () => { + await queryClient.invalidateQueries({ + queryKey: getGetUserInventoryQueryKey({ unit_id: unitId }), + }); + }, [queryClient, unitId]); + + const { data } = useGetUserInventory({ unit_id: unitId }); + const { data: unit } = useGetUnit(unitId); + const { data: unitUserList } = useGetOrganisationUnitUsers(unitId); + + const [pivot, setPivot] = useState(false); + + if (data === undefined || unit === undefined || unitUserList === undefined) { + return ; + } + + const unitEditors = unitUserList.users.map((user) => user.id); + const users = data.users + .filter( + (user) => + user.projects.observer.length + + user.projects.editor.length + + user.projects.administrator.length > + 0 || + unitEditors.includes(user.username) || + user.username === unit.owner_id, + ) + .map((user) => ({ ...user, isEditor: unitEditors.includes(user.username) })); + + const pivotToggle = ( + setPivot(event.target.checked)} />} + label="Pivot" + labelPlacement="start" + /> + ); + + return ( + + + User Usage + + + Unit: {unit.name} + + + Owner: {unit.owner_id} + + {pivot ? ( + + ) : ( + + )} + + + ); +}; diff --git a/components/usage/UserUsageByProjectTable.tsx b/components/usage/UserUsageByProjectTable.tsx new file mode 100644 index 000000000..9227804bc --- /dev/null +++ b/components/usage/UserUsageByProjectTable.tsx @@ -0,0 +1,149 @@ +import type { ReactNode } from "react"; +import { useMemo, useState } from "react"; + +import type { InventoryUserDetail } from "@squonk/data-manager-client"; + +import { Edit } from "@mui/icons-material"; +import { Chip } from "@mui/material"; +import { createColumnHelper } from "@tanstack/react-table"; +import groupBy from "just-group-by"; + +import { Chips } from "../Chips"; +import { DataTable } from "../DataTable"; +import { EditProjectModal } from "../projects/EditProjectButton/EditProjectModal"; + +type PivotProject = { + project_id: string; + name: string; + observers: string[]; + editors: string[]; + administrators: string[]; +}; + +const columnHelper = createColumnHelper(); + +const UserChips = ({ users }: { users: string[] }) => ( + + {users.map((user: string) => ( + + ))} + +); + +const columns = [ + columnHelper.accessor("name", { header: "Project" }), + columnHelper.group({ + header: "Users", + columns: [ + columnHelper.accessor("observers", { + header: "Observers", + cell: ({ getValue }) => , + }), + columnHelper.accessor("editors", { + header: "Editors", + cell: ({ getValue }) => , + }), + columnHelper.accessor("administrators", { + header: "Administrators", + cell: ({ getValue }) => , + }), + columnHelper.display({ + id: "icon", + cell: () => , + }), + ], + }), +]; + +const pivotProjects = (users: InventoryUserDetail[]) => { + // add username to each project and flatten them all to a single array + const flat_projects = users + .map((user) => + [ + user.projects.observer.map((project) => ({ + ...project, + username: user.username, + permission: "observer", + })), + user.projects.editor.map((project) => ({ + ...project, + username: user.username, + permission: "editor", + })), + user.projects.administrator.map((project) => ({ + ...project, + username: user.username, + permission: "administrator", + })), + ].flat(), + ) + .flat(); + + // group usernames by project + // create a key from the id and name (even though the id is unique, this is so we keep the name and id together) + return Object.entries(groupBy(flat_projects, (project) => project.id + "+" + project.name)).map( + ([key, projects]) => ({ + project_id: key.slice(0, key.indexOf("+")), + name: key.slice(key.indexOf("+") + 1), + observers: projects + .filter((project) => project.permission === "observer") + .map((project) => project.username), + editors: projects + .filter((project) => project.permission === "editor") + .map((project) => project.username), + administrators: projects + .filter((project) => project.permission === "administrator") + .map((project) => project.username), + }), + ) satisfies PivotProject[]; +}; + +export interface UserUsageByProjectTableProps { + /** + * users to display + */ + users: InventoryUserDetail[]; + /** + * callback for when a change is made, usually used to invalidate queries + */ + onChange: () => Promise; + /** + * toolbar content + */ + toolbarContent?: ReactNode; +} + +export const UserUsageByProjectTable = ({ + users, + onChange, + toolbarContent, +}: UserUsageByProjectTableProps) => { + const projects = useMemo(() => pivotProjects(users), [users]); + const [selectedProjectId, setSelectedProjectId] = useState(undefined); + const selectedProject = projects.find((project) => project.project_id === selectedProjectId); + + return ( + <> + {selectedProject && ( + setSelectedProjectId(undefined)} + onMemberChange={onChange} + /> + )} + ({ + hover: true, + sx: { cursor: "pointer" }, + onClick: () => { + setSelectedProjectId(row.original.project_id); + }, + })} + data={projects} + toolbarContent={toolbarContent} + /> + + ); +}; diff --git a/components/usage/UserUsageTable.tsx b/components/usage/UserUsageTable.tsx new file mode 100644 index 000000000..a40c623b6 --- /dev/null +++ b/components/usage/UserUsageTable.tsx @@ -0,0 +1,257 @@ +import type { ReactNode } from "react"; +import { useMemo } from "react"; + +import type { InventoryProjectDetail, InventoryUserDetail } from "@squonk/data-manager-client"; +import { + useAddAdministratorToProject, + useAddEditorToProject, + useAddObserverToProject, + useRemoveAdministratorFromProject, + useRemoveEditorFromProject, + useRemoveObserverFromProject, +} from "@squonk/data-manager-client/project"; + +import { Close, Done } from "@mui/icons-material"; +import type { UseAutocompleteProps } from "@mui/material"; +import { Autocomplete, Chip, TextField, Typography } from "@mui/material"; +import { createColumnHelper } from "@tanstack/react-table"; +import dayjs from "dayjs"; +import utc from "dayjs/plugin/utc"; + +import { DATE_FORMAT, TIME_FORMAT } from "../../constants/datetimes"; +import { useEnqueueError } from "../../hooks/useEnqueueStackError"; +import { DataTable } from "../DataTable"; + +dayjs.extend(utc); + +type UserEntry = InventoryUserDetail & { isEditor: boolean }; + +const columnHelper = createColumnHelper(); + +const getProjectsList = (users: UserEntry[]) => + users + .map((user) => Object.values(user.projects).flat()) + .flat() + .reduce((uniqueProjects, project) => { + if (!uniqueProjects.some((p) => p.id === project.id)) { + uniqueProjects.push(project); + } + return uniqueProjects; + }, []); + +export interface UserUsageTableProps { + /** + * list of users with associated projects + */ + users: UserEntry[]; + /** + * Function to call when a user's project membership is changed + */ + onChange: () => Promise; + /** + * Content to display in the toolbar + */ + toolbarContent?: ReactNode; +} + +export const UserUsageTable = ({ users, toolbarContent, onChange }: UserUsageTableProps) => { + const columns = useMemo( + () => [ + columnHelper.accessor("username", { header: "User" }), + columnHelper.accessor("isEditor", { + header: "Unit Editor", + cell: ({ row }) => (row.original.isEditor ? : ), + }), + columnHelper.accessor("first_seen", { + header: "First Seen", + cell: ({ getValue }) => dayjs.utc(getValue()).format(`${DATE_FORMAT} ${TIME_FORMAT}`), + sortingFn: (a, b) => + dayjs.utc(a.original.first_seen).diff(dayjs.utc(b.original.first_seen)), + }), + columnHelper.accessor((user) => user.activity.active_days, { + id: "activity", + header: "Activity", + }), + columnHelper.accessor("last_seen_date", { + header: "Last Seen", + cell: ({ getValue }) => dayjs.utc(getValue()).format(DATE_FORMAT), + sortingFn: (a, b) => + dayjs.utc(a.original.last_seen_date).diff(dayjs.utc(b.original.last_seen_date)), + }), + columnHelper.group({ + header: "Datasets", + columns: [ + columnHelper.accessor((user) => user.datasets.editor?.length ?? 0, { header: "Editor" }), + columnHelper.accessor((user) => user.datasets.owner?.length ?? 0, { header: "Owner" }), + ], + }), + columnHelper.group({ + header: "Project Membership", + columns: [ + columnHelper.accessor((user) => user.projects.observer, { + header: "Observer", + cell: ({ getValue, row }) => ( + + ), + }), + columnHelper.accessor((user) => user.projects.editor, { + id: "project-editor", + header: "Editor", + cell: ({ getValue, row }) => ( + + ), + }), + columnHelper.accessor((user) => user.projects.administrator, { + header: "Administrator", + cell: ({ getValue, row }) => ( + + ), + }), + ], + }), + ], + [users, onChange], + ); + + return ( + <> + + + A user is considered active in a given day if they have used the Data Manager API + + + ); +}; + +interface ChipsInputProps { + users: UserEntry[]; + value: InventoryProjectDetail[]; + group: "observer" | "editor" | "administrator"; + username: string; + onChange: () => Promise; +} + +type Handler = UseAutocompleteProps["onChange"]; + +const ChipsInput = ({ users, value, group, username, onChange }: ChipsInputProps) => { + const projects = useMemo(() => getProjectsList(users), [users]); + + const { isPending: isPending0, mutateAsync: addObserver } = useAddObserverToProject(); + const { isPending: isPending1, mutateAsync: removeObserver } = useRemoveObserverFromProject(); + const { isPending: isPending2, mutateAsync: addEditor } = useAddEditorToProject(); + const { isPending: isPending3, mutateAsync: removeEditor } = useRemoveEditorFromProject(); + const { isPending: isPending4, mutateAsync: addAdministrator } = useAddAdministratorToProject(); + const { isPending: isPending5, mutateAsync: removeAdministrator } = + useRemoveAdministratorFromProject(); + + const isPending = + isPending0 || isPending1 || isPending2 || isPending3 || isPending4 || isPending5; + + const { enqueueError, enqueueSnackbar } = useEnqueueError(); + + const handleChange: Handler = async (_e, _v, reason, details) => { + if (details?.option) { + try { + switch (reason) { + case "selectOption": { + switch (group) { + case "observer": { + await addObserver({ projectId: details.option.id, userId: username }); + await onChange(); + enqueueSnackbar("Observer added", { variant: "success" }); + break; + } + case "editor": { + await addEditor({ projectId: details.option.id, userId: username }); + await onChange(); + enqueueSnackbar("Editor added", { variant: "success" }); + break; + } + case "administrator": { + await addAdministrator({ projectId: details.option.id, userId: username }); + await onChange(); + enqueueSnackbar("Administrator added", { variant: "success" }); + break; + } + } + break; + } + + case "removeOption": { + switch (group) { + case "observer": { + await removeObserver({ projectId: details.option.id, userId: username }); + await onChange(); + enqueueSnackbar("Observer removed", { variant: "success" }); + break; + } + case "editor": { + await removeEditor({ projectId: details.option.id, userId: username }); + await onChange(); + enqueueSnackbar("Editor removed", { variant: "success" }); + break; + } + case "administrator": { + await removeAdministrator({ projectId: details.option.id, userId: username }); + await onChange(); + enqueueSnackbar("Administrator removed", { variant: "success" }); + break; + } + } + break; + } + } + } catch (error) { + enqueueError(error); + } + } + }; + + return ( + option.name} + isOptionEqualToValue={(option, value) => option.id === value.id} + loading={isPending} + options={projects} + renderInput={(params) => ( + + )} + renderTags={(value, getTagProps) => + value.map((option, index) => { + const { key, ...props } = getTagProps({ index }); + + return ; + }) + } + size="small" + value={value} + onChange={handleChange} + /> + ); +}; diff --git a/components/userContext/SelectUnit.tsx b/components/userContext/SelectUnit.tsx index 4381ea4ea..f47aa52cc 100644 --- a/components/userContext/SelectUnit.tsx +++ b/components/userContext/SelectUnit.tsx @@ -1,6 +1,8 @@ +import type { ReactNode } from "react"; + import type { UnitGetResponse } from "@squonk/account-server-client"; -import { Receipt as ReceiptIcon } from "@mui/icons-material"; +import { DataUsage as DataUsageIcon, Receipt as ReceiptIcon } from "@mui/icons-material"; import type { AutocompleteProps } from "@mui/material"; import { Autocomplete, Box, IconButton, TextField, Tooltip, Typography } from "@mui/material"; @@ -13,6 +15,27 @@ import { getErrorMessage } from "../../utils/next/orvalError"; import type { PermissionLevelFilter } from "./filter"; import { ItemIcons } from "./ItemIcons"; +interface AdornmentProps { + title: string; + href: string; + children: ReactNode; +} + +const Adornment = ({ title, href, children }: AdornmentProps) => ( + + + + {children} + + + +); + export interface SelectUnitProps extends Omit, "renderInput" | "options"> { userFilter: PermissionLevelFilter; @@ -55,20 +78,14 @@ export const SelectUnit = ({ <> {!!unit && ( - - - - - - - + <> + + + + + + + )} ), diff --git a/features/DatasetsTable/DatasetDetails/VersionActionsSection/DatasetSchemaListItem/DatasetSchemaViewModal/DatasetSchemaViewModal.tsx b/features/DatasetsTable/DatasetDetails/VersionActionsSection/DatasetSchemaListItem/DatasetSchemaViewModal/DatasetSchemaViewModal.tsx index df8ef35e2..dfc9bd93d 100644 --- a/features/DatasetsTable/DatasetDetails/VersionActionsSection/DatasetSchemaListItem/DatasetSchemaViewModal/DatasetSchemaViewModal.tsx +++ b/features/DatasetsTable/DatasetDetails/VersionActionsSection/DatasetSchemaListItem/DatasetSchemaViewModal/DatasetSchemaViewModal.tsx @@ -186,7 +186,7 @@ export const DatasetSchemaViewModal: FC = ({ data={fields} getRowId={getRowId} tableContainer={false} - ToolbarChild={ + toolbarContent={ { initialSelection={[]} isLoading={isLoading} ToolbarActionChild={} - ToolbarChild={ + toolbarContent={ <> - {(isEditor || isAdministrator) && } + {(isEditor || isAdministrator) && ( + + {({ openDialog }) => { + return ( + + + + + + + + ); + }} + + )} {isCreator && } {(isEditor || isAdministrator) && } diff --git a/features/ProjectStats/ProjectStatsSection.tsx b/features/ProjectStats/ProjectStatsSection.tsx index b1352ecb7..f3b785494 100644 --- a/features/ProjectStats/ProjectStatsSection.tsx +++ b/features/ProjectStats/ProjectStatsSection.tsx @@ -132,14 +132,13 @@ export const ProjectStatsSection = ({ userFilter }: ProjectStatsSectionProps) => header: "", enableSorting: false, }), - datasetStorageColumnHelper.display({ id: "for-layout-only-2", enableSorting: false }), datasetStorageColumnHelper.display({ id: "usage", header: "Usage", enableSorting: false, cell: ({ row }) => , }), - datasetStorageColumnHelper.display({ id: "for-layout-only-3", enableSorting: false }), + datasetStorageColumnHelper.display({ id: "for-layout-only-2", enableSorting: false }), datasetStorageColumnHelper.accessor((row) => row.storage.coins.used, { id: "used", header: "Used", @@ -183,7 +182,7 @@ export const ProjectStatsSection = ({ userFilter }: ProjectStatsSectionProps) => }, "& tr": { display: "grid", - gridTemplateColumns: "61px 1fr 1fr 1fr 110px 220px 100px 90px 100px 130px", + gridTemplateColumns: "61px repeat(4, 1fr) 220px repeat(4, 130px)", }, }, }} @@ -204,7 +203,7 @@ export const ProjectStatsSection = ({ userFilter }: ProjectStatsSectionProps) => }, "& tr": { display: "grid", - gridTemplateColumns: "61px 1fr 110px 220px 100px 90px 100px 130px", + gridTemplateColumns: "61px 1fr 220px repeat(4, 130px)", }, }, }} diff --git a/features/ProjectTable/ProjectTable.tsx b/features/ProjectTable/ProjectTable.tsx index f81dc0c5a..5a55a05e6 100644 --- a/features/ProjectTable/ProjectTable.tsx +++ b/features/ProjectTable/ProjectTable.tsx @@ -147,7 +147,7 @@ export const ProjectTable = ({ currentProject, openUploadDialog }: ProjectTableP error={getErrorMessage(error)} getRowId={(row) => row.fullPath} isLoading={isLoading} - ToolbarChild={ + toolbarContent={ diff --git a/features/usage/UnitUsageView.tsx b/features/usage/UnitUsageView.tsx new file mode 100644 index 000000000..0aeb4efa0 --- /dev/null +++ b/features/usage/UnitUsageView.tsx @@ -0,0 +1,21 @@ +import { useGetUnit } from "@squonk/account-server-client/unit"; + +import Head from "next/head"; + +import { UnitUserUsage } from "../../components/usage/UnitUserUsage"; + +export interface UnitUsageViewProps { + unitId: string; +} + +export const UnitUsageView = ({ unitId }: UnitUsageViewProps) => { + const { data } = useGetUnit(unitId); + return ( + <> + + Squonk | {data?.name} - User Usage + + + + ); +}; diff --git a/features/userSettings/UserSettingsContent/UserSettingsContent.tsx b/features/userSettings/UserSettingsContent/UserSettingsContent.tsx index 20a3c14b2..58a7982ec 100644 --- a/features/userSettings/UserSettingsContent/UserSettingsContent.tsx +++ b/features/userSettings/UserSettingsContent/UserSettingsContent.tsx @@ -82,7 +82,7 @@ export const UserSettingsContent = () => { } return ( - + diff --git a/pages/unit/[unitId]/user-usage.tsx b/pages/unit/[unitId]/user-usage.tsx new file mode 100644 index 000000000..a39931089 --- /dev/null +++ b/pages/unit/[unitId]/user-usage.tsx @@ -0,0 +1,28 @@ +import { withPageAuthRequired as withPageAuthRequiredCSR } from "@auth0/nextjs-auth0/client"; +import { useRouter } from "next/router"; + +import { RoleRequired } from "../../../components/auth/RoleRequired"; +import { AS_ROLES, DM_ROLES } from "../../../constants/auth"; +import { UnitUsageView } from "../../../features/usage/UnitUsageView"; +import Layout from "../../../layouts/Layout"; + +const UserUsage = () => { + const { query } = useRouter(); + const unitId = query.unitId; + + if (typeof unitId !== "string") { + return null; + } + + return ( + + + + + + + + ); +}; + +export default withPageAuthRequiredCSR(UserUsage); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e4fc2a36a..4ba0b1bca 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -319,8 +319,8 @@ packages: engines: {node: '>=10.0.0'} dev: true - /@emnapi/runtime@1.1.0: - resolution: {integrity: sha512-gCGlE0fJGWalfy+wbFApjhKn6uoSVvopru77IPyxNKkjkaiSx2HxDS7eOYSmo9dcMIhmmIvoxiC3N9TM1c3EaA==} + /@emnapi/runtime@1.1.1: + resolution: {integrity: sha512-3bfqkzuR1KLx57nZfjr2NLnFOobvyS0aTszaEGCGqmYMVDRaGvgIZbjGSV/MHSSmLgQ/b9JFHQ5xm5WRZYd+XQ==} requiresBuild: true dependencies: tslib: 2.6.2 @@ -334,7 +334,7 @@ packages: '@babel/runtime': 7.23.9 '@emotion/hash': 0.9.1 '@emotion/memoize': 0.8.1 - '@emotion/serialize': 1.1.4 + '@emotion/serialize': 1.1.3 babel-plugin-macros: 3.1.0 convert-source-map: 1.9.0 escape-string-regexp: 4.0.0 @@ -728,7 +728,7 @@ packages: cpu: [wasm32] requiresBuild: true dependencies: - '@emnapi/runtime': 1.1.0 + '@emnapi/runtime': 1.1.1 dev: false optional: true diff --git a/types/nextjs-routes.d.ts b/types/nextjs-routes.d.ts index c9a6e8ea2..1af0f824a 100644 --- a/types/nextjs-routes.d.ts +++ b/types/nextjs-routes.d.ts @@ -44,6 +44,7 @@ declare module "nextjs-routes" { | StaticRoute<"/results"> | StaticRoute<"/run"> | DynamicRoute<"/unit/[unitId]/charges", { "unitId": string }> + | DynamicRoute<"/unit/[unitId]/user-usage", { "unitId": string }> | StaticRoute<"/viewer/sdf">; interface StaticRoute {