Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found
Select Git revision
  • adrian/group-management
  • adrian/un-mock
  • master
3 results

Target

Select target project
  • fsi/idm/self-service-frontend
1 result
Select Git revision
  • adrian/group-management
  • adrian/un-mock
  • master
3 results
Show changes
Commits on Source (2)
......@@ -13,6 +13,7 @@ const LazyAccountPage = lazy(() => import("../pages/AccountPage"));
const LazyDiscordLinkPage = lazy(() => import("../pages/DiscordLinkPage"));
const LazyGroupListPage = lazy(() => import("../pages/GroupListPage"));
const LazyGroupManagementPage = lazy(() => import("../pages/GroupManagementPage"));
const LazyGroupAddMembersPage = lazy(() => import("../pages/GroupAddMembersPage"));
const LazyContactPage = lazy(() => import("../pages/ContactPage"));
const LazyWidgetsPage = lazy(() => import("../pages/WidgetsPage"));
......@@ -27,6 +28,7 @@ const router = createBrowserRouter(
<Route path="/groups">
<Route path="" element={<AuthGuard element={<LazyGroupListPage />} />} />
<Route path=":groupId" element={<AuthGuard element={<LazyGroupManagementPage />} />} />
<Route path=":groupId/add" element={<AuthGuard element={<LazyGroupAddMembersPage />} />} />
</Route>
<Route path="/contact" element={<LazyContactPage />} />
<Route path="/auth/:type/callback" element={<OAuthCallbackPage />} />
......
import { Link, useParams } from "react-router";
import { H2 } from "../widgets/typography/Heading";
import type { ApiGroup } from "../../data/schemas";
import { UserList } from "../widgets/group/UserList";
import type { UserListItemAddonProps, GroupMember } from "../widgets/group/UserList";
import ChevronRight from "../../icons/ChevronRight.svg?react";
import Check from "../../icons/Check.svg?react";
import UserPlus from "../../icons/UserPlus.svg?react";
import { STYLES } from "../../constants";
import Button from "../widgets/Button";
import { useCallback, useMemo, useState } from "react";
import { Input } from "../forms/Input";
import { UserListItemAppendix } from "../widgets/group/UserListItem";
const mockGroupMembers: GroupMember[] = [
{ id: "1", name: "Adrian der Große" },
{ id: "2", name: "Tobi der Kleine" },
{ id: "3", name: "Lukas" },
];
const mockFoundMembers: GroupMember[] = [
{ id: "1", name: "Adrian der Große" },
{ id: "2", name: "Tobi der Kleine" },
{ id: "3", name: "Lukas" },
{ id: "4", name: "Lukas 2" },
{ id: "5", name: "Lukas 3" },
{ id: "6", name: "Lukas der Vierte" },
];
const mockGroup: ApiGroup = {
id: "7",
name: "Tobis Folterkeller",
parentGroupId: "6",
memberCount: 1,
};
export default function GroupAddMembersPage() {
const { groupId } = useParams<{ groupId: string }>();
const group = {
...mockGroup,
id: groupId!,
};
return (
<>
<div className="grid grid-cols-[auto_1fr_auto] items-center justify-center gap-2 mt-3 mb-4">
<Link
to={`/groups/${groupId}`}
className={`w-8 h-8 grid justify-center items-center rounded-full ${STYLES.CLICKABLE} shrink-0`}
viewTransition>
<ChevronRight className="h-5 rotate-180 fill-current relative top-0.5" />
</Link>
<H2 className="break-words !m-0">{group.name}</H2>
</div>
<ServerSearchUserList group={group} members={mockGroupMembers} />
</>
);
}
interface ServerSearchUserListProps {
group: ApiGroup;
members: GroupMember[];
}
export function ServerSearchUserList({ group, members }: ServerSearchUserListProps) {
const [searchQuery, setSearchQuery] = useState("");
const existingMemberIds = useMemo(() => new Set(members.map(m => m.id)), [members]);
const foundUsers = mockFoundMembers;
return (
<>
<Input
placeholder="Search users to add..."
className="w-full mt-8 mb-4"
onChange={e => setSearchQuery(e.target.value)}
/>
<UserList
title="Add Users to Group"
users={foundUsers}
group={group}
emptyPlaceholderText={
searchQuery.length > 0 ? "No members match your search query." : "Enter a search query to find users."
}
itemAddon={p => <AddMemberAddon {...p} isMember={existingMemberIds.has(p.user.id)} />}
/>
</>
);
}
function AddMemberAddon({ user, group, isMember }: UserListItemAddonProps & { isMember: boolean }) {
const [isLoading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const onClick = useCallback(() => {
if (isMember) {
return;
}
setError(null);
setLoading(true);
setTimeout(() => {
setLoading(false);
setError("Random HTTP error yada yada testing");
}, 2000);
// TODO Handle addition
}, [isMember]);
const Icon = isMember ? Check : UserPlus;
return (
<>
<Button
outlined
kind={isMember ? "success" : "info"}
size="small"
disabled={isLoading || isMember}
loading={isLoading}
onClick={onClick}
className="rounded-full justify-self-end">
<Icon className="w-4 h-4 fill-current" />
</Button>
{error ? <UserListItemAppendix className={STYLES.ERROR_COLOR}>{error}</UserListItemAppendix> : null}
</>
);
}
import { Link, useParams } from "react-router";
import { H2 } from "../widgets/typography/Heading";
import type { ApiGroup } from "../../data/schemas";
import UserList from "../widgets/group/UserList";
import type { UserListItemAddonProps, GroupMember } from "../widgets/group/UserList";
import { SearchableUserList } from "../widgets/group/UserList";
import ChevronRight from "../../icons/ChevronRight.svg?react";
import UserPlus from "../../icons/UserPlus.svg?react";
import XMark from "../../icons/XMark.svg?react";
import { STYLES } from "../../constants";
import Button from "../widgets/Button";
import type { MouseEvent } from "react";
import { useCallback, useMemo, useState } from "react";
import { Input } from "../forms/Input";
import Fuse from "fuse.js";
import { useCallback, useState } from "react";
import { UserListItemAppendix } from "../widgets/group/UserListItem";
const mockMembers: GroupMember[] = [
{ id: "1", name: "Adrian der Große" },
......@@ -26,7 +25,6 @@ const mockGroup: ApiGroup = {
memberCount: 1,
};
// TODO Add search bar
export default function GroupManagementPage() {
const { groupId } = useParams<{ groupId: string }>();
......@@ -37,7 +35,7 @@ export default function GroupManagementPage() {
return (
<>
<div className="grid grid-cols-[auto_1fr_auto] items-center justify-center gap-2 mt-3 mb-4">
<div className="grid grid-cols-[auto_1fr_auto] items-center justify-center gap-2 mt-2 mb-4">
<Link
to="/groups"
className={`w-8 h-8 grid justify-center items-center rounded-full ${STYLES.CLICKABLE} shrink-0`}
......@@ -45,42 +43,19 @@ export default function GroupManagementPage() {
<ChevronRight className="h-5 rotate-180 fill-current relative top-0.5" />
</Link>
<H2 className="break-words !m-0">{group.name}</H2>
<Button outlined>
<Button as="link" to={`/groups/${groupId}/add`} outlined>
<UserPlus className="w-4 h-4 fill-current" />
Add Members
</Button>
</div>
<SearchableUserList group={group} users={mockMembers} />
</>
);
}
interface SearchableUserListProps {
group: ApiGroup;
users: GroupMember[];
}
function SearchableUserList({ group, users }: SearchableUserListProps) {
const [searchQuery, setSearchQuery] = useState("");
const fuse = useMemo(() => new Fuse(users, { keys: ["name"] }), [users]);
const filteredUsers = useMemo(
() => (searchQuery.length > 0 ? fuse.search(searchQuery).map(result => result.item) : null),
[fuse, searchQuery]
);
return (
<>
<Input
placeholder="Search members..."
className="w-full mt-8 mb-4"
onChange={e => setSearchQuery(e.target.value)}
/>
<UserList
users={filteredUsers ?? users}
<SearchableUserList
title="Group Members"
searchPlaceholder="Search members..."
emptyPlaceholderText="There are no members in this group."
emptyPlaceholderTextSearch="No members match your search query."
group={group}
isFiltered={searchQuery.length > 0}
users={mockMembers}
itemAddon={p => <RemoveMemberAddon {...p} />}
/>
</>
......@@ -91,16 +66,16 @@ function RemoveMemberAddon({ user, group }: UserListItemAddonProps) {
const [isLoading, setLoading] = useState(false);
const [showRemovalConfirmation, setShowRemovalConfirmation] = useState(false);
const onRemoveConfirmClick = useCallback(() => {
const onConfirmClick = useCallback(() => {
setLoading(true);
// TODO Handle Removal
}, []);
const onRemoveCancelClick = useCallback(() => {
const onCancelClick = useCallback(() => {
setShowRemovalConfirmation(false);
}, []);
const onRemoveClick = useCallback(
const onClick = useCallback(
(e: MouseEvent) => {
if (isLoading) {
return;
......@@ -108,35 +83,34 @@ function RemoveMemberAddon({ user, group }: UserListItemAddonProps) {
if (e.getModifierState("Shift")) {
// Skip confirmation prompt
setShowRemovalConfirmation(true);
onRemoveConfirmClick();
onConfirmClick();
return;
}
setShowRemovalConfirmation(!showRemovalConfirmation);
},
[isLoading, showRemovalConfirmation, onRemoveConfirmClick]
[isLoading, showRemovalConfirmation, onConfirmClick]
);
const cancellationConfirmation = showRemovalConfirmation ? (
<div
className={`col-span-full mt-2 -ml-4 -mr-2 px-4 pt-4 pb-2 text-center border-t ${STYLES.BORDER_COLOR} border-dotted`}>
const removalConfirmation = showRemovalConfirmation ? (
<UserListItemAppendix>
Do you really want to remove <strong>{user.name}</strong> from the group <strong>{group.name}</strong>?
<div className="flex flex-row gap-4 justify-center mt-4">
<Button kind="success" disabled={isLoading} onClick={onRemoveCancelClick}>
<Button kind="success" disabled={isLoading} onClick={onCancelClick}>
Nevermind
</Button>
<Button kind="danger" outlined loading={isLoading} onClick={onRemoveConfirmClick}>
<Button kind="danger" outlined loading={isLoading} onClick={onConfirmClick}>
Remove
</Button>
</div>
</div>
</UserListItemAppendix>
) : null;
return (
<>
<Button outlined kind="danger" size="small" disabled={isLoading} onClick={onRemoveClick} className="rounded-full">
<Button outlined kind="danger" size="small" disabled={isLoading} onClick={onClick} className="rounded-full">
<XMark className="w-4 h-4 fill-current" />
</Button>
{cancellationConfirmation}
{removalConfirmation}
</>
);
}
import classNames from "classnames";
import Spinner from "./Spinner";
import type { LinkProps } from "react-router";
import { Link } from "react-router";
type ButtonKind = "info" | "success" | "warn" | "danger" | "link";
type ButtonSize = "small" | "normal" | "large";
type ButtonOrLinkProps =
| (React.ButtonHTMLAttributes<HTMLButtonElement> & { as?: "button" })
| (React.AnchorHTMLAttributes<HTMLAnchorElement> & { as: "a" });
| (React.AnchorHTMLAttributes<HTMLAnchorElement> & { as: "a" })
| (LinkProps & { as: "link" });
type Props = ButtonOrLinkProps & {
kind?: ButtonKind;
......@@ -71,7 +74,7 @@ export default function Button({ kind: buttonStyle, size, outlined, loading, cla
className
);
if (!("href" in props)) {
if (!("href" in props) && !("to" in props)) {
props.type ??= "button";
}
......@@ -95,6 +98,13 @@ export default function Button({ kind: buttonStyle, size, outlined, loading, cla
</a>
);
}
if (props.as === "link") {
return (
<Link viewTransition {...props} {...commonProps}>
{commonChildren}
</Link>
);
}
return (
<button {...props} {...commonProps}>
{commonChildren}
......
import UserListItem from "./UserListItem";
import { GridTable, GridTableEmpty, GridTableRow, GridTableTitle } from "../GridTable";
import type { ApiGroup } from "../../../data/schemas";
import { useMemo, useState } from "react";
import Fuse from "fuse.js";
import { Input } from "../../forms/Input";
export interface GroupMember {
id: string;
......@@ -13,21 +16,18 @@ export interface UserListItemAddonProps {
}
interface Props {
title: string;
emptyPlaceholderText: string;
group: ApiGroup;
users: GroupMember[];
isFiltered?: boolean;
itemAddon?: (data: UserListItemAddonProps) => React.ReactNode;
}
export default function UserList({ users, group, isFiltered, itemAddon }: Props) {
export function UserList({ title, emptyPlaceholderText, users, group, itemAddon }: Props) {
return (
<GridTable className="grid-cols-[auto_1fr_auto] gap-1.5 pb-2">
<GridTableTitle>Group Members</GridTableTitle>
{!users.length && (
<GridTableEmpty>
{isFiltered ? "No users matched your search query." : "No members bruh, go add some."}
</GridTableEmpty>
)}
<GridTable className="grid-cols-[auto_1fr_auto] gap-1.5 pb-1.5">
<GridTableTitle>{title}</GridTableTitle>
{!users.length && <GridTableEmpty>{emptyPlaceholderText}</GridTableEmpty>}
{users.map(user => (
<GridTableRow key={user.id}>
<UserListItem user={user}>{itemAddon?.({ user, group })}</UserListItem>
......@@ -36,3 +36,33 @@ export default function UserList({ users, group, isFiltered, itemAddon }: Props)
</GridTable>
);
}
type SearchableUserListProps = Omit<Props, "isFiltered"> & {
searchPlaceholder: string;
emptyPlaceholderTextSearch: string;
};
export function SearchableUserList(props: SearchableUserListProps) {
const [searchQuery, setSearchQuery] = useState("");
const fuse = useMemo(() => new Fuse(props.users, { keys: ["name"] }), [props.users]);
const filteredUsers = useMemo(
() => (searchQuery.length > 0 ? fuse.search(searchQuery).map(result => result.item) : null),
[fuse, searchQuery]
);
return (
<>
<Input
placeholder={props.searchPlaceholder}
className="w-full mt-8 mb-4"
onChange={e => setSearchQuery(e.target.value)}
/>
<UserList
{...props}
emptyPlaceholderText={searchQuery.length > 0 ? props.emptyPlaceholderTextSearch : props.emptyPlaceholderText}
users={filteredUsers ?? props.users}
/>
</>
);
}
import { useMemo } from "react";
import type { GroupMember } from "./UserList";
import { generateInitials } from "../../../utils/utils";
import type { WithChildren } from "../../../utils/types";
import type { WithChildren, WithChildrenAndClassName } from "../../../utils/types";
import classNames from "classnames";
import { STYLES } from "../../../constants";
interface Props {
user: GroupMember;
......@@ -34,3 +36,15 @@ function UserIcon({ user }: UserIconProps) {
return <div className={classes}>{initials}</div>;
}
export function UserListItemAppendix({ className, children }: WithChildrenAndClassName) {
return (
<div
className={classNames(
`col-span-full mt-2 -ml-4 -mr-2 px-4 pt-4 pb-2 text-center border-t ${STYLES.BORDER_COLOR} border-dotted`,
className
)}>
{children}
</div>
);
}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--!Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M438.6 105.4c12.5 12.5 12.5 32.8 0 45.3l-256 256c-12.5 12.5-32.8 12.5-45.3 0l-128-128c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0L160 338.7 393.4 105.4c12.5-12.5 32.8-12.5 45.3 0z"/></svg>