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

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 (3)
Showing
with 656 additions and 241 deletions
module.exports = {
env: {
es6: true,
browser: true,
},
settings: {
react: {
version: "detect",
parser: "@typescript-eslint/parser",
parserOptions: {
project: "tsconfig.json",
tsconfigRootDir: __dirname,
sourceType: "module",
ecmaFeatures: {
jsx: true,
},
},
plugins: [
"@typescript-eslint",
"prefer-arrow",
"react",
"react-hooks",
],
extends: [
"eslint:recommended",
"plugin:react/recommended",
......@@ -18,21 +24,19 @@ module.exports = {
"plugin:import/errors",
"plugin:import/warnings",
"plugin:import/typescript",
"prettier",
],
parser: "@typescript-eslint/parser",
parserOptions: {
project: "tsconfig.json",
sourceType: "module",
ecmaFeatures: {
jsx: true,
root: true,
env: {
es6: true,
browser: true,
},
settings: {
react: {
version: "detect",
},
},
plugins: [
"@typescript-eslint",
"prefer-arrow",
"react",
"react-hooks",
],
ignorePatterns: [".eslintrc.js"],
rules: {
"@typescript-eslint/array-type": "error",
"@typescript-eslint/ban-types": "off",
......@@ -40,7 +44,7 @@ module.exports = {
"@typescript-eslint/consistent-type-definitions": ["error", "interface"],
"@typescript-eslint/consistent-type-imports": "error",
"@typescript-eslint/dot-notation": "error",
"@typescript-eslint/explicit-member-accessibility": "error",
"@typescript-eslint/explicit-member-accessibility": ["error", { "accessibility": "no-public" }],
"@typescript-eslint/explicit-module-boundary-types": "off",
"@typescript-eslint/member-delimiter-style": "error",
"@typescript-eslint/no-duplicate-enum-values": "warn",
......@@ -83,13 +87,7 @@ module.exports = {
"eol-last": "error",
"eqeqeq": "error",
"guard-for-in": "error",
"import/no-unresolved": ["error", { ignore: ["\\.svg\\?react$"] }],
indent: ["error", "tab", {
"SwitchCase": 1,
}],
"max-len": ["error", {
code: 140,
}],
"import/no-unresolved": ["error", { ignore: ["\\.svg\\?react$", "\\?worker$"] }],
"new-parens": "error",
"no-caller": "error",
"no-console": "error",
......
......@@ -8,6 +8,8 @@
"name": "fsi-self-service",
"version": "1.0.0",
"dependencies": {
"@tanstack/react-query": "^5.62.11",
"@tanstack/react-query-devtools": "^5.62.11",
"classnames": "^2.5.1",
"fuse.js": "^7.0.0",
"react": "^19.0.0",
......@@ -29,6 +31,7 @@
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"eslint": "^8.57.1",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-prefer-arrow": "^1.2.3",
"eslint-plugin-react": "^7.37.3",
......@@ -1877,6 +1880,59 @@
"postcss": "^8.2.15"
}
},
"node_modules/@tanstack/query-core": {
"version": "5.62.9",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.62.9.tgz",
"integrity": "sha512-lwePd8hNYhyQ4nM/iRQ+Wz2cDtspGeZZHFZmCzHJ7mfKXt+9S301fULiY2IR2byJYY6Z03T427E5PoVfMexHjw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/query-devtools": {
"version": "5.62.9",
"resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.62.9.tgz",
"integrity": "sha512-b1NZzDLVf6laJsB1Cfm3ieuYzM+WqoO8qpm9v+3Etwd+Ph4zkhUMiT+wcWj5AhEPsXiRodKYiiW048VDNdBxNg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/react-query": {
"version": "5.62.11",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.62.11.tgz",
"integrity": "sha512-Xb1nw0cYMdtFmwkvH9+y5yYFhXvLRCnXoqlzSw7UkqtCVFq3cG8q+rHZ2Yz1XrC+/ysUaTqbLKJqk95mCgC1oQ==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.62.9"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^18 || ^19"
}
},
"node_modules/@tanstack/react-query-devtools": {
"version": "5.62.11",
"resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.62.11.tgz",
"integrity": "sha512-i0vKgdM4ORRzqduz7UeUF52UhLrvRp4sNY/DnLsd5NqNyiKct3a0bLQMWE2fqjF5tEExQ0d0xY60ILXW/T62xA==",
"license": "MIT",
"dependencies": {
"@tanstack/query-devtools": "5.62.9"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"@tanstack/react-query": "^5.62.11",
"react": "^18 || ^19"
}
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
......@@ -3344,6 +3400,19 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/eslint-config-prettier": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz",
"integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==",
"dev": true,
"license": "MIT",
"bin": {
"eslint-config-prettier": "bin/cli.js"
},
"peerDependencies": {
"eslint": ">=7.0.0"
}
},
"node_modules/eslint-import-resolver-node": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz",
......
......@@ -11,6 +11,8 @@
"format": "prettier --write ."
},
"dependencies": {
"@tanstack/react-query": "^5.62.11",
"@tanstack/react-query-devtools": "^5.62.11",
"classnames": "^2.5.1",
"fuse.js": "^7.0.0",
"react": "^19.0.0",
......@@ -32,6 +34,7 @@
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"eslint": "^8.57.1",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-prefer-arrow": "^1.2.3",
"eslint-plugin-react": "^7.37.3",
......
{
"loading": "Loading…",
"retry": "Nochmal versuchen",
"login.title": "Login",
"login.description": "Bitte melde dich im neu geöffneten Fenster in deinen RUB-Account ein.",
......@@ -10,6 +11,7 @@
"navigation.account": "Account",
"navigation.discord-link": "Discord Link",
"navigation.groups": "Gruppen",
"navigation.contact": "Kontakt",
"navigation.services": "Webdienste",
......@@ -58,6 +60,33 @@
"widgets.discord.join": "Beitreten",
"widgets.discord.joined": "Beigetreten",
"groups.title": "Gruppenverwaltung",
"groups.description": "Hier kannst du die Mitglieder von Gruppen verwalten, auf die du entsprechenden Zugriff hast.",
"groups.loading": "Lade Gruppen…",
"groups.loading.error": "Fehler beim Laden der Gruppen",
"groups.members.loading": "Lade Gruppenmitglieder…",
"groups.members.loading.error": "Fehler beim Laden der Gruppenmitglieder",
"groups.members.add": "Mitglieder Hinzufügen",
"groups.members.list-title": "Gruppenmitglieder",
"groups.members.search-placeholder": "Mitglieder durchsuchen…",
"groups.members.empty": "Diese Gruppe hat keine Mitglieder.",
"groups.members.no-results": "Keine Mitglieder passen zu deiner Suchanfrage.",
"groups.members.remove.button-label": "{userName} aus Gruppe {groupName} entfernen",
"groups.members.remove.warning": "Möchtest du wirklich <b>{userName}</b> aus Gruppe <b>{groupName}</b> entfernen?",
"groups.members.remove.confirm": "Entfernen",
"groups.members.remove.cancel": "Abbrechen",
"groups.members.remove.error": "<b>Fehler:</b> {errorMessage}",
"groups.members.add.list-title": "Nutzer hinzufügen",
"groups.members.add.search-placeholder": "Suche nach Nutzern, die du hinzufügen möchtest…",
"groups.members.add.empty": "Gib einen Suchbegriff ein, um Nutzer zu finden, die du hinzufügen kannst.",
"groups.members.add.no-results": "Keine Nutzer passen zu deiner Suchanfrage.",
"groups.members.add.button-label": "{userName} zu Gruppe {groupName} hinzufügen",
"groups.members.add.button-label.success": "{userName} wurde erfolgreich zu {groupName} hinzugefügt",
"groups.members.add.error": "<b>Fehler:</b> {errorMessage}",
"widgets.group-list.title": "Gruppen",
"widgets.group-list.empty": "Es gibt keine Gruppen, die du verwalten kannst.",
"widgets.group-list.item.member-count": "{members, plural, one {Ein Mitglied} other {# Mitglieder}}",
"contact.title": "Kontakt",
"contact.howto": "Falls es Probleme mit deinem Account oder unseren Diensten gibt, kannst du uns unter {email} erreichen.",
......
{
"loading": "Loading…",
"retry": "Try again",
"login.title": "Login",
"login.description": "Please log into your RUB Account in the newly opened window.",
......@@ -10,6 +11,7 @@
"navigation.account": "Account",
"navigation.discord-link": "Discord Link",
"navigation.groups": "Groups",
"navigation.contact": "Contact",
"navigation.services": "Web Services",
......@@ -58,6 +60,33 @@
"widgets.discord.join": "Join",
"widgets.discord.joined": "Joined",
"groups.title": "Group Management",
"groups.description": "Here you can manage the members of groups that you have management access to.",
"groups.loading": "Loading Groups…",
"groups.loading.error": "Error loading Groups",
"groups.members.loading": "Loading group members…",
"groups.members.loading.error": "Error loading group members",
"groups.members.add": "Add Members",
"groups.members.list-title": "Group Members",
"groups.members.search-placeholder": "Search Members…",
"groups.members.empty": "There are no mebers in this group.",
"groups.members.no-results": "No members match your search query.",
"groups.members.remove.button-label": "Remove {userName} from group {groupName}",
"groups.members.remove.warning": "Do you really want to remove <b>{userName}</b> from the group <b>{groupName}</b>?",
"groups.members.remove.confirm": "Remove",
"groups.members.remove.cancel": "Nevermind",
"groups.members.remove.error": "<b>Error:</b> {errorMessage}",
"groups.members.add.list-title": "Add users to group",
"groups.members.add.search-placeholder": "Search for users to add…",
"groups.members.add.empty": "Enter a search query to find users to add.",
"groups.members.add.no-results": "No users match your search query.",
"groups.members.add.button-label": "Add {userName} to group {groupName}",
"groups.members.add.button-label.success": "Successfully added {userName} to {groupName}",
"groups.members.add.error": "<b>Error:</b> {errorMessage}",
"widgets.group-list.title": "Groups",
"widgets.group-list.empty": "There are no groups you can manage.",
"widgets.group-list.item.member-count": "{members, plural, one {One Member} other {# Members}}",
"contact.title": "Contact",
"contact.howto": "If you encounter any issues with your account or our services, you can contact us at {email}.",
......
......@@ -15,10 +15,9 @@ export default function Navigation({ className }: WithClassName) {
<ul>
<NavigationLink name={intl("navigation.account")} to="/account" />
<NavigationLink name={intl("navigation.discord-link")} to="/discord" />
<NavigationLink name="GROUPS" to="/groups" />
<NavigationLink name={intl("navigation.groups")} to="/groups" />
<NavigationLink name={intl("navigation.contact")} to="/contact" />
<NavigationLink name={intl("navigation.services")} to={SERVICES_LIST_URL} />
<NavigationLink name="WIDGETS" to="/debug-widgets" />
{isLoggedIn && <NavigationLink name={intl("logout")} to="/logout" />}
</ul>
</nav>
......
import { Link, useParams } from "react-router";
import { H2 } from "../widgets/typography/Heading";
import type { ApiGroup } from "../../data/schemas";
import { useParams } from "react-router";
import { H2WithBackButton } from "../widgets/typography/Heading";
import type { ApiGetGroupResponse, ApiGroup, ApiGroupUser } 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 type { UserListItemAddonProps } from "../widgets/group/UserList";
import Check from "../../icons/Check.svg?react";
import UserPlus from "../../icons/UserPlus.svg?react";
import { STYLES } from "../../constants";
......@@ -11,78 +10,82 @@ import Button from "../widgets/Button";
import { useCallback, useMemo, useState } from "react";
import { Input } from "../forms/Input";
import { UserListItemAppendix } from "../widgets/group/UserListItem";
import useIntlMessages from "../i18n/useIntlMessages";
import { useAddGroupMemberMutation, useGroupQuery } from "../../data/queries/groups";
import Message from "../i18n/Message";
import { ApiLoader } from "../widgets/ApiLoader";
const mockGroupMembers: GroupMember[] = [
{ id: "1", name: "Adrian der Große" },
{ id: "2", name: "Tobi der Kleine" },
{ id: "3", name: "Lukas" },
];
// const mockGroupMembers: ApiGroupUser[] = [
// { 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 mockFoundMembers: ApiGroupUser[] = [
// { 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,
};
// const mockGroup: ApiGroup = {
// id: "7",
// name: "Tobis Folterkeller",
// parent_id: "6",
// member_count: 1,
// };
export default function GroupAddMembersPage() {
const intl = useIntlMessages();
const { groupId } = useParams<{ groupId: string }>();
const groupQuery = useGroupQuery(groupId ?? "");
const group = {
...mockGroup,
id: groupId!,
};
const renderUserList = useCallback((data: ApiGetGroupResponse) => {
const { members, ...group } = data;
return <ServerSearchUserList group={group} members={members} />;
}, []);
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>
<H2WithBackButton to={`/groups/${groupId}`}>{groupQuery.data?.name ?? groupId}</H2WithBackButton>
<ServerSearchUserList group={group} members={mockGroupMembers} />
<ApiLoader
query={groupQuery}
renderComponent={renderUserList}
loadingMessage={intl("groups.members.loading")}
errorMessage={intl("groups.members.loading.error")}
/>
</>
);
}
interface ServerSearchUserListProps {
group: ApiGroup;
members: GroupMember[];
members: ApiGroupUser[];
}
export function ServerSearchUserList({ group, members }: ServerSearchUserListProps) {
const intl = useIntlMessages();
const [searchQuery, setSearchQuery] = useState("");
const existingMemberIds = useMemo(() => new Set(members.map(m => m.id)), [members]);
const foundUsers = mockFoundMembers;
const foundUsers: ApiGroupUser[] = []; // TODO
return (
<>
<Input
placeholder="Search users to add..."
placeholder={intl("groups.members.add.search-placeholder")}
className="w-full mt-8 mb-4"
onChange={e => setSearchQuery(e.target.value)}
/>
<UserList
title="Add Users to Group"
title={intl("groups.members.add.list-title")}
users={foundUsers}
group={group}
emptyPlaceholderText={
searchQuery.length > 0 ? "No members match your search query." : "Enter a search query to find users."
searchQuery.length > 0 ? intl("groups.members.add.no-results") : intl("groups.members.add.empty")
}
itemAddon={p => <AddMemberAddon {...p} isMember={existingMemberIds.has(p.user.id)} />}
/>
......@@ -91,21 +94,16 @@ export function ServerSearchUserList({ group, members }: ServerSearchUserListPro
}
function AddMemberAddon({ user, group, isMember }: UserListItemAddonProps & { isMember: boolean }) {
const [isLoading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const intl = useIntlMessages();
const addMemberMutation = useAddGroupMemberMutation(group.id, user.id);
const isLoading = addMemberMutation.isPending;
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]);
addMemberMutation.mutate();
}, [isMember, addMemberMutation]);
const Icon = isMember ? Check : UserPlus;
......@@ -118,10 +116,25 @@ function AddMemberAddon({ user, group, isMember }: UserListItemAddonProps & { is
disabled={isLoading || isMember}
loading={isLoading}
onClick={onClick}
className="rounded-full justify-self-end">
className="rounded-full justify-self-end"
aria-label={
isMember
? intl("groups.members.add.button-label.success", {
userName: user.name,
groupName: group.name,
})
: intl("groups.members.add.button-label", {
userName: user.name,
groupName: group.name,
})
}>
<Icon className="w-4 h-4 fill-current" />
</Button>
{error ? <UserListItemAppendix className={STYLES.ERROR_COLOR}>{error}</UserListItemAppendix> : null}
{addMemberMutation.error ? (
<UserListItemAppendix className={STYLES.ERROR_COLOR}>
<Message id="groups.members.add.error" vars={{ errorMessage: addMemberMutation.error.message }} />
</UserListItemAppendix>
) : null}
</>
);
}
import type { ApiGroup } from "../../data/schemas";
import { useCallback } from "react";
import { useGroupsQuery } from "../../data/queries/groups";
import Message from "../i18n/Message";
import useIntlMessages from "../i18n/useIntlMessages";
import { ApiLoader } from "../widgets/ApiLoader";
import GroupList from "../widgets/group/GroupList";
import { H2 } from "../widgets/typography/Heading";
import P from "../widgets/typography/Paragraph";
import type { ApiGetGroupsResponse } from "../../data/schemas";
const allGroups: ApiGroup[] = [
{
id: "1",
name: "Fachschaft AI",
parentGroupId: null,
memberCount: 1337,
},
{
id: "2",
name: "Merry NOCmas",
parentGroupId: null,
memberCount: 0,
},
{
id: "3",
name: "AI, AI, wir fahren nach AI",
parentGroupId: "1",
memberCount: 42,
},
{
id: "4",
name: "AI Serveradmins",
parentGroupId: "1",
memberCount: 3,
},
{
id: "5",
name: "Olegs Elfen",
parentGroupId: "1",
memberCount: 0,
},
{
id: "6",
name: "Unsichtbare Einhörner",
parentGroupId: "5",
memberCount: 1,
},
{
id: "7",
name: "Tobis Folterkeller",
parentGroupId: "6",
memberCount: 1,
},
{
id: "8",
name: "OhmygodOhmygodOhmygodOhmygodOhmygodOhmygodOhmygodOhmygodOhmygodOhmygodOhmygodOhmygodOhmygodOhmygodOhmygodOhmygod",
parentGroupId: null,
memberCount: 999999999,
},
];
// const allGroups: ApiGroup[] = [
// {
// id: "1",
// name: "Fachschaft AI",
// parent_id: null,
// member_count: 1337,
// },
// {
// id: "2",
// name: "Merry NOCmas",
// parent_id: null,
// member_count: 0,
// },
// {
// id: "3",
// name: "AI, AI, wir fahren nach AI",
// parent_id: "1",
// member_count: 42,
// },
// {
// id: "4",
// name: "AI Serveradmins",
// parent_id: "1",
// member_count: 3,
// },
// {
// id: "5",
// name: "Olegs Elfen",
// parent_id: "1",
// member_count: 0,
// },
// {
// id: "6",
// name: "Unsichtbare Einhörner",
// parent_id: "5",
// member_count: 1,
// },
// {
// id: "7",
// name: "Tobis Folterkeller",
// parent_id: "6",
// member_count: 1,
// },
// {
// id: "8",
// name: "OhmygodOhmygodOhmygodOhmygodOhmygodOhmygodOhmygodOhmygodOhmygodOhmygodOhmygodOhmygodOhmygodOhmygodOhmygodOhmygod",
// parent_id: null,
// member_count: 999999999,
// },
// ];
export default function GroupManagementPage() {
const intl = useIntlMessages();
const renderGroupList = useCallback((groups: ApiGetGroupsResponse) => <GroupList groups={groups} />, []);
return (
<>
<H2>Group Management</H2>
<P>You can manage members of your owned groups here or something idk.</P>
<GroupList groups={allGroups} />
<H2>
<Message id="groups.title" />
</H2>
<P>
<Message id="groups.description" />
</P>
<ApiLoader
query={useGroupsQuery()}
renderComponent={renderGroupList}
loadingMessage={intl("groups.loading")}
errorMessage={intl("groups.loading.error")}
/>
</>
);
}
import { Link, useParams } from "react-router";
import { H2 } from "../widgets/typography/Heading";
import type { ApiGroup } from "../../data/schemas";
import type { UserListItemAddonProps, GroupMember } from "../widgets/group/UserList";
import { useParams } from "react-router";
import { H2WithBackButton } from "../widgets/typography/Heading";
import type { UserListItemAddonProps } 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";
......@@ -11,64 +9,81 @@ import Button from "../widgets/Button";
import type { MouseEvent } from "react";
import { useCallback, useState } from "react";
import { UserListItemAppendix } from "../widgets/group/UserListItem";
import Message from "../i18n/Message";
import useIntlMessages from "../i18n/useIntlMessages";
import { useGroupQuery, useRemoveGroupMemberMutation } from "../../data/queries/groups";
import { ApiLoader } from "../widgets/ApiLoader";
import { ApiGetGroupResponse } from "../../data/schemas";
const mockMembers: GroupMember[] = [
{ id: "1", name: "Adrian der Große" },
{ id: "2", name: "Tobi der Kleine" },
{ id: "3", name: "Lukas" },
];
// const mockMembers: ApiGroupUser[] = [
// { id: "1", name: "Adrian der Große" },
// { id: "2", name: "Tobi der Kleine" },
// { id: "3", name: "Lukas" },
// ];
const mockGroup: ApiGroup = {
id: "7",
name: "Tobis Folterkeller",
parentGroupId: "6",
memberCount: 1,
};
// const mockGroup: ApiGroup = {
// id: "7",
// name: "Tobis Folterkeller",
// parent_id: "6",
// member_count: 1,
// };
export default function GroupManagementPage() {
const intl = useIntlMessages();
const { groupId } = useParams<{ groupId: string }>();
const group = {
...mockGroup,
id: groupId!,
};
const groupQuery = useGroupQuery(groupId ?? "");
const renderAddon = useCallback((props: UserListItemAddonProps) => <RemoveMemberAddon {...props} />, []);
const renderUserList = useCallback(
(data: ApiGetGroupResponse) => {
const { members, ...group } = data;
return (
<SearchableUserList
title={intl("groups.members.list-title")}
searchPlaceholder={intl("groups.members.search-placeholder")}
emptyPlaceholderText={intl("groups.members.empty")}
emptyPlaceholderTextSearch={intl("groups.members.no-results")}
group={group}
users={members}
itemAddon={renderAddon}
/>
);
},
[renderAddon]
);
return (
<>
<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`}
viewTransition>
<ChevronRight className="h-5 rotate-180 fill-current relative top-0.5" />
</Link>
<H2 className="break-words !m-0">{group.name}</H2>
<Button as="link" to={`/groups/${groupId}/add`} outlined>
<UserPlus className="w-4 h-4 fill-current" />
Add Members
</Button>
</div>
<H2WithBackButton
to="/groups"
appendix={
<Button as="link" to={`/groups/${groupId}/add`} outlined>
<UserPlus className="w-4 h-4 fill-current" />
<Message id="groups.members.add" />
</Button>
}>
{groupQuery.data?.name ?? groupId}
</H2WithBackButton>
<SearchableUserList
title="Group Members"
searchPlaceholder="Search members..."
emptyPlaceholderText="There are no members in this group."
emptyPlaceholderTextSearch="No members match your search query."
group={group}
users={mockMembers}
itemAddon={p => <RemoveMemberAddon {...p} />}
<ApiLoader
query={groupQuery}
renderComponent={renderUserList}
loadingMessage={intl("groups.members.loading")}
errorMessage={intl("groups.members.loading.error")}
/>
</>
);
}
function RemoveMemberAddon({ user, group }: UserListItemAddonProps) {
const [isLoading, setLoading] = useState(false);
const intl = useIntlMessages();
const [showRemovalConfirmation, setShowRemovalConfirmation] = useState(false);
const removeMemberMutation = useRemoveGroupMemberMutation(group.id, user.id);
const isLoading = removeMemberMutation.isPending;
const onConfirmClick = useCallback(() => {
setLoading(true);
// TODO Handle Removal
removeMemberMutation.mutate();
}, []);
const onCancelClick = useCallback(() => {
......@@ -93,21 +108,42 @@ function RemoveMemberAddon({ user, group }: UserListItemAddonProps) {
const removalConfirmation = showRemovalConfirmation ? (
<UserListItemAppendix>
Do you really want to remove <strong>{user.name}</strong> from the group <strong>{group.name}</strong>?
<Message
id="groups.members.remove.warning"
vars={{
userName: user.name,
groupName: group.name,
}}
/>
<div className="flex flex-row gap-4 justify-center mt-4">
<Button kind="success" disabled={isLoading} onClick={onCancelClick}>
Nevermind
<Message id="groups.members.remove.cancel" />
</Button>
<Button kind="danger" outlined loading={isLoading} onClick={onConfirmClick}>
Remove
<Message id="groups.members.remove.confirm" />
</Button>
</div>
{removeMemberMutation.error && (
<div className={`mt-4 ${STYLES.ERROR_COLOR}`}>
<Message id="groups.members.remove.error" vars={{ errorMessage: removeMemberMutation.error.message }} />
</div>
)}
</UserListItemAppendix>
) : null;
return (
<>
<Button outlined kind="danger" size="small" disabled={isLoading} onClick={onClick} className="rounded-full">
<Button
outlined
kind="danger"
size="small"
disabled={removeMemberMutation.isPending}
onClick={onClick}
className="rounded-full"
aria-label={intl("groups.members.remove.button-label", {
userName: user.name,
groupName: group.name,
})}>
<XMark className="w-4 h-4 fill-current" />
</Button>
{removalConfirmation}
......
import type { UseQueryResult } from "@tanstack/react-query";
import LoadingIndicator from "./LoadingIndicator";
import { ErrorBox } from "./ErrorBox";
interface Props<TData> {
query: UseQueryResult<TData, Error>;
renderComponent: (data: TData) => React.ReactNode;
loadingMessage: string;
errorMessage: string;
}
export function ApiLoader<TData>({ query, renderComponent, loadingMessage, errorMessage }: Props<TData>) {
const { isPending, data, error, refetch } = query;
if (isPending) {
return <LoadingIndicator className="mt-16">{loadingMessage}</LoadingIndicator>;
}
if (error) {
return <ErrorBox message={errorMessage} details={error.message} retry={() => void refetch()} />;
}
return renderComponent(data);
}
import { STYLES } from "../../constants";
import Button from "./Button";
import Bug from "../../icons/Bug.svg?react";
import type { WithClassName } from "../../utils/types";
import classNames from "classnames";
import Message from "../i18n/Message";
interface Props {
message: string;
details: string;
retry?: () => void;
}
export function ErrorBox({ message, details, retry, className }: WithClassName<Props>) {
return (
<div
className={classNames(
`flex justify-center text-center border rounded-md ${STYLES.BORDER_COLOR} leading-none`,
className
)}>
<div className="grid p-4 gap-x-8 gap-y-4 grid-cols-1 sm:grid-cols-[auto_1fr] sm:justify-items-center">
<Bug className={`w-12 h-12 fill-current ${STYLES.ERROR_COLOR} sm:row-span-3 justify-self-center self-center`} />
<div className={`${STYLES.ERROR_COLOR}`}>{message}</div>
<div className="text-sm">{details}</div>
{retry && (
<Button onClick={retry}>
<Message id="retry" />
</Button>
)}
</div>
</div>
);
}
......@@ -2,6 +2,7 @@ import { useMemo } from "react";
import GroupListItem from "./GroupListItem";
import type { ApiGroup } from "../../../data/schemas";
import { GridTable, GridTableEmpty, GridTableRow, GridTableTitle } from "../GridTable";
import Message from "../../i18n/Message";
interface Props {
groups: ApiGroup[];
......@@ -15,8 +16,14 @@ export default function GroupList({ groups }: Props) {
return (
<GridTable className="grid-cols-[1fr_auto] md:grid-cols-[1.333fr_1fr_auto] gap-x-4">
<GridTableTitle>Groups</GridTableTitle>
{!sortedGroups.length && <GridTableEmpty>There are no groups you can manage.</GridTableEmpty>}
<GridTableTitle>
<Message id="widgets.group-list.title" />
</GridTableTitle>
{!sortedGroups.length && (
<GridTableEmpty>
<Message id="widgets.group-list.empty" />
</GridTableEmpty>
)}
{sortedGroups.map(group => (
<GridTableRow key={group.id}>
<GroupListItem group={group} />
......@@ -37,8 +44,8 @@ function createSortedGroupHierarchy(groups: ApiGroup[]): GroupWithLevel[] {
// Create hierarchy
const rootGroups: GroupWithChildren[] = [];
for (const group of groupMap.values()) {
if (group.parentGroupId) {
const parentGroup = groupMap.get(group.parentGroupId);
if (group.parent_id) {
const parentGroup = groupMap.get(group.parent_id);
if (parentGroup) {
parentGroup.children.push(group);
} else {
......
......@@ -4,6 +4,7 @@ import { STYLES } from "../../../constants";
import type { GroupWithLevel } from "./GroupList";
import { Link } from "react-router";
import classNames from "classnames";
import Message from "../../i18n/Message";
interface Props {
group: GroupWithLevel;
......@@ -30,7 +31,14 @@ export default function GroupListItem({ group }: Props) {
{group.level > 0 && <ArrowTurnUp className="w-3 h-3 rotate-90 fill-slate-400 dark:fill-slate-500" />}
{group.name}
</div>
<div className={classNames(STYLES.MUTED_COLOR)}>{group.memberCount} members</div>
<div className={classNames(STYLES.MUTED_COLOR)}>
<Message
id="widgets.group-list.item.member-count"
vars={{
members: group.member_count,
}}
/>
</div>
</div>
<ChevronRight className="w-4 h-4 fill-current self-center justify-self-end" />
</Link>
......
import UserListItem from "./UserListItem";
import { GridTable, GridTableEmpty, GridTableRow, GridTableTitle } from "../GridTable";
import type { ApiGroup } from "../../../data/schemas";
import type { ApiGroup, ApiGroupUser } from "../../../data/schemas";
import { useMemo, useState } from "react";
import Fuse from "fuse.js";
import { Input } from "../../forms/Input";
export interface GroupMember {
id: string;
name: string;
}
export interface UserListItemAddonProps {
user: GroupMember;
user: ApiGroupUser;
group: ApiGroup;
}
......@@ -19,7 +14,7 @@ interface Props {
title: string;
emptyPlaceholderText: string;
group: ApiGroup;
users: GroupMember[];
users: ApiGroupUser[];
itemAddon?: (data: UserListItemAddonProps) => React.ReactNode;
}
......
import { useMemo } from "react";
import type { GroupMember } from "./UserList";
import { generateInitials } from "../../../utils/utils";
import type { WithChildren, WithChildrenAndClassName } from "../../../utils/types";
import classNames from "classnames";
import { STYLES } from "../../../constants";
import type { ApiGroupUser } from "../../../data/schemas";
interface Props {
user: GroupMember;
user: ApiGroupUser;
}
export default function UserListItem({ user, children }: WithChildren<Props>) {
......@@ -20,7 +20,7 @@ export default function UserListItem({ user, children }: WithChildren<Props>) {
}
interface UserIconProps {
user: GroupMember;
user: ApiGroupUser;
}
function UserIcon({ user }: UserIconProps) {
......
import classNames from "classnames";
import { Link } from "react-router";
import type { WithChildren } from "../../../utils/types";
import { STYLES } from "../../../constants";
import ChevronRight from "../../../icons/ChevronRight.svg?react";
interface HeadingProps {
level: 1 | 2 | 3 | 4 | 5 | 6;
......@@ -18,3 +22,25 @@ export function H2(props: React.HTMLAttributes<HTMLHeadingElement>) {
export function H3(props: React.HTMLAttributes<HTMLHeadingElement>) {
return <Heading level={3} baseClasses="font-bold text-xl mt-8 mb-4 first:mt-2" {...props} />;
}
interface H2WithBackButtonProps {
to: string;
appendix?: React.ReactNode;
}
export function H2WithBackButton({ to, children, appendix }: WithChildren<H2WithBackButtonProps>) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 items-center gap-4 mt-3 mb-4">
<div className="flex flex-row gap-2">
<Link
to={to}
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">{children}</H2>
</div>
<div className="md:justify-self-end">{appendix}</div>
</div>
);
}
......@@ -9,7 +9,7 @@ export const STYLES = {
// Text Colors
TEXT_COLOR: "text-gray-800 dark:text-gray-300",
MUTED_COLOR: "text-gray-500 dark:text-gray-400",
ERROR_COLOR: "text-red-700 dark:text-red-500",
ERROR_COLOR: "text-red-700 dark:text-red-400",
BORDER_COLOR: "border-slate-300 dark:border-slate-600",
LINK_COLOR: "text-blue-700 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300",
......
import type { GenericSchema } from "valibot";
import { safeParse } from "valibot";
import type { FlatErrors, GenericSchema } from "valibot";
import { flatten, safeParse } from "valibot";
import { useLoginStore } from "./state/login";
import { API_BASE_URL, APP_VERSION } from "../constants";
import {
ApiGetMeResponseSchema,
......@@ -8,10 +9,14 @@ import {
ApiDeleteDiscordResponseSchema,
ApiPostAuthRubResponseSchema,
ApiPostAuthDiscordTokenResponseSchema,
ApiGetGroupsResponseSchema,
ApiGetGroupResponseSchema,
} from "./schemas";
import type {
ApiDeleteDiscordResponse,
ApiGetDiscordResponse,
ApiGetGroupResponse,
ApiGetGroupsResponse,
ApiGetMeResponse,
ApiPatchMeRequest,
ApiPatchMeResponse,
......@@ -21,26 +26,47 @@ import type {
ApiPostAuthRubTokenRequest,
} from "./schemas";
import { useLoginStore } from "./state/login";
/* eslint-disable no-mixed-spaces-and-tabs */
export type ApiResponse<T> = {
status: number;
ok: boolean;
} & (
| {
error: false;
ok: true;
data: T;
}
| {
error: true;
ok: false;
data: ApiErrorResponse;
}
);
/* eslint-enable no-mixed-spaces-and-tabs */
export interface ApiErrorResponse {
error: string;
message: string;
issues?: FlatErrors<undefined>;
}
export class ApiError extends Error {
public constructor(
public readonly method: string,
public readonly route: string,
public readonly status: number,
public readonly response: ApiErrorResponse
) {
let message = `HTTP ${status} @ ${method} ${route}: ${response.message}`;
if (response.issues) {
message += "\n" + JSON.stringify(response.issues, null, 4);
}
super(message);
}
}
type RequestMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "HEAD";
export interface RequestOptions {
errorHook?: (error: ApiError) => void;
jsonBody?: unknown;
multipartBody?: FormData;
params?: URLSearchParams;
tokenOverride?: string;
}
export default class ApiRequests {
......@@ -49,7 +75,9 @@ export default class ApiRequests {
}
public static async patchMe(data: ApiPatchMeRequest): Promise<ApiResponse<ApiPatchMeResponse>> {
return this.request("PATCH", "/me", ApiPatchMeResponseSchema, data);
return this.request("PATCH", "/me", ApiPatchMeResponseSchema, {
jsonBody: data,
});
}
public static async getDiscord(): Promise<ApiResponse<ApiGetDiscordResponse>> {
......@@ -60,14 +88,34 @@ export default class ApiRequests {
return this.request("DELETE", "/discord", ApiDeleteDiscordResponseSchema);
}
public static async getGroups(): Promise<ApiGetGroupsResponse> {
return this.tryRequest("GET", "/groups", ApiGetGroupsResponseSchema);
}
public static async getGroup(groupId: string): Promise<ApiGetGroupResponse> {
return this.tryRequest("GET", `/groups/${groupId}`, ApiGetGroupResponseSchema);
}
public static async addGroupMember(groupId: string, userId: string): Promise<void> {
return this.tryRequest("PUT", `/groups/${groupId}/members/${userId}`, null);
}
public static async removeGroupMember(groupId: string, userId: string): Promise<void> {
return this.tryRequest("DELETE", `/groups/${groupId}/members/${userId}`, null);
}
public static async postAuthRubToken(data: ApiPostAuthRubTokenRequest): Promise<ApiResponse<ApiPostAuthRubResponse>> {
return this.request("POST", "/auth/rub/token", ApiPostAuthRubResponseSchema, data);
return this.request("POST", "/auth/rub/token", ApiPostAuthRubResponseSchema, {
jsonBody: data,
});
}
public static async postAuthDiscordToken(
data: ApiPostAuthDiscordTokenRequest
): Promise<ApiResponse<ApiPostAuthDiscordTokenResponse>> {
return this.request("POST", "/auth/discord/token", ApiPostAuthDiscordTokenResponseSchema, data);
return this.request("POST", "/auth/discord/token", ApiPostAuthDiscordTokenResponseSchema, {
jsonBody: data,
});
}
public static async postLogout(): Promise<ApiResponse<void>> {
......@@ -78,23 +126,51 @@ export default class ApiRequests {
return useLoginStore.getState().token;
}
private static async tryRequest<T = void>(
method: RequestMethod,
route: string,
schema: GenericSchema<T> | null,
options?: RequestOptions
): Promise<T> {
const response = await this.request(method, route, schema, options);
if (!response.ok) {
const error = new ApiError(method, route, response.status, response.data);
if (options?.errorHook) {
options.errorHook(error);
}
throw error;
}
return response.data;
}
private static async request<T = void>(
method: string,
route: String,
method: RequestMethod,
route: string,
schema: GenericSchema<T> | null,
body?: unknown
options?: RequestOptions
): Promise<ApiResponse<T>> {
const url = `${API_BASE_URL}${route}`;
const url = `${API_BASE_URL}${route}${options?.params ? `?${options.params}` : ""}`;
const headers = new Headers({
"User-Agent": `FSI Self-Service v${APP_VERSION}`,
Accept: "application/json",
});
if (this.token) {
headers.set("Authorization", this.token);
const token = options?.tokenOverride ?? this.token;
if (token) {
headers.set("Authorization", `Bearer ${token}`);
}
const canHaveBody = method !== "GET" && method !== "HEAD";
if (canHaveBody && body) {
headers.set("Content-Type", "application/json");
let body: BodyInit | null = null;
if (canHaveBody) {
if (options?.multipartBody) {
if (options.jsonBody) {
options.multipartBody.set("payload_json", JSON.stringify(options.jsonBody));
}
body = options.multipartBody;
} else if (options?.jsonBody) {
body = JSON.stringify(options.jsonBody);
headers.set("Content-Type", "application/json");
}
}
let response: Response;
......@@ -102,36 +178,36 @@ export default class ApiRequests {
response = await fetch(url, {
method,
headers,
body: !canHaveBody || !body ? undefined : JSON.stringify(body),
body,
credentials:
url.startsWith(API_BASE_URL) && /^https?:\/\/localhost(:\d+)?/.test(url) ? "include" : "same-origin",
});
} catch (e: unknown) {
return {
status: 0,
ok: false,
error: true,
data: {
error: e instanceof Error ? e.message : "Unknown Network Error",
message: e instanceof Error ? e.message : "Unknown Network Error",
},
};
}
const baseResultFields = {
status: response.status,
ok: response.ok,
};
let json: any = {};
const text = await response.text();
if (text && response.headers.get("content-type") === "application/json") {
if (text && response.headers.get("content-type")?.startsWith("application/json")) {
try {
json = JSON.parse(text);
} catch {
const limit = 200;
return {
...baseResultFields,
error: true,
ok: false,
data: {
error: `Unable to parse response as JSON: ${
message: `Unable to parse response as JSON: ${
text.length <= limit ? text : `${text.substring(0, limit)}...`
}`,
},
......@@ -140,19 +216,23 @@ export default class ApiRequests {
}
if (!response.ok) {
const data =
"message" in json
? json
: {
message: `${method} ${route} returned status ${response.status} with body ${JSON.stringify(json, null, 4)}`,
};
return {
...baseResultFields,
error: true,
data: {
error: `${method} ${route} returned status ${response.status}: ${json.error ?? "Unknown Error"}`,
},
ok: false,
data,
};
}
if (!schema) {
return {
...baseResultFields,
error: false,
ok: true,
data: undefined as T,
};
}
......@@ -161,16 +241,17 @@ export default class ApiRequests {
if (!validationResult.success) {
return {
...baseResultFields,
error: true,
ok: false,
data: {
error: validationResult.issues.map(i => i.message).join(", "),
message: "Response Validation Error",
issues: flatten(validationResult.issues),
},
};
}
return {
...baseResultFields,
error: false,
ok: true,
data: validationResult.output,
};
}
......
import { useMutation, useQuery } from "@tanstack/react-query";
import ApiRequests from "../ApiRequests";
import { useCallback } from "react";
export const useGroupsQuery = () =>
useQuery({
queryKey: ["groups"],
queryFn: useCallback(() => ApiRequests.getGroups(), []),
});
export const useGroupQuery = (groupId: string) =>
useQuery({
queryKey: ["groups", groupId],
queryFn: useCallback(() => ApiRequests.getGroup(groupId), [groupId]),
});
export const useAddGroupMemberMutation = (groupId: string, userId: string) =>
useMutation({
mutationFn: useCallback(() => ApiRequests.addGroupMember(groupId, userId), [groupId, userId]),
});
export const useRemoveGroupMemberMutation = (groupId: string, userId: string) =>
useMutation({
mutationFn: useCallback(() => ApiRequests.removeGroupMember(groupId, userId), [groupId, userId]),
});
......@@ -62,13 +62,34 @@ export type ApiDeleteDiscordResponse = ApiGetDiscordResponse;
export const ApiGroupSchema = object({
id: string(),
name: string(),
parentGroupId: nullable(string()),
memberCount: pipe(number(), integer()),
parent_id: nullable(string()),
member_count: pipe(number(), integer()),
});
export type ApiGroup = InferOutput<typeof ApiGroupSchema>;
export const ApiGetGroupsResponseSchema = array(ApiGroupSchema);
export type ApiGetGroupsResponse = InferOutput<typeof ApiGetGroupsResponseSchema>;
// --- GET /groups/:group_id ---
export const ApiGroupUserSchema = object({
id: string(),
name: string(),
});
export type ApiGroupUser = InferOutput<typeof ApiGroupUserSchema>;
export const ApiGetGroupResponseSchema = object({
...ApiGroupSchema.entries,
members: array(ApiGroupUserSchema),
});
export type ApiGetGroupResponse = InferOutput<typeof ApiGetGroupResponseSchema>;
// --- PUT /groups/:group_id/members/:userId ---
// 204 No Content
// --- DELETE /groups/:group_id/members/:userId ---
// 204 No Content
// --- POST /auth/rub/token ---
export interface ApiPostAuthRubTokenRequest {
......