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)
export default class Logger {
public static debug(tag: string, message: string, data?: any): void {
static debug(tag: string, message: string, data?: any): void {
// eslint-disable-next-line no-console, @typescript-eslint/no-unsafe-argument
console.debug(...Logger.createLogArgs("debug", tag, message, data));
}
public static info(tag: string, message: string, data?: any): void {
static info(tag: string, message: string, data?: any): void {
// eslint-disable-next-line no-console, @typescript-eslint/no-unsafe-argument
console.info(...Logger.createLogArgs("info", tag, message, data));
}
public static warn(tag: string, message: string, data?: any): void {
static warn(tag: string, message: string, data?: any): void {
// eslint-disable-next-line no-console, @typescript-eslint/no-unsafe-argument
console.warn(...Logger.createLogArgs("warn", tag, message, data));
}
public static error(tag: string, message: string, data?: any): void {
static error(tag: string, message: string, data?: any): void {
// eslint-disable-next-line no-console, @typescript-eslint/no-unsafe-argument
console.error(...Logger.createLogArgs("error", tag, message, data));
}
......
......@@ -77,9 +77,11 @@
"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.search-placeholder": "Suche nach Nutzern, die du hinzufügen möchtest…",
"groups.members.add.search-loading": "Suche nach Nutzern…",
"groups.members.add.search-error": "Fehler bei der Nutzersuche",
"groups.members.add.search-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}",
......
......@@ -77,9 +77,11 @@
"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.search-placeholder": "Search for users to add…",
"groups.members.add.search-loading": "Looking for users…",
"groups.members.add.search-error": "Error searching for users",
"groups.members.add.search-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}",
......
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 { SearchableUserList } from "../widgets/group/UserList";
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";
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 { useAddGroupMemberMutation, useGroupQuery, useSearchUsersQuery } from "../../data/queries/groups";
import Message from "../i18n/Message";
import { ApiLoader } from "../widgets/ApiLoader";
// const mockGroupMembers: ApiGroupUser[] = [
// { id: "1", name: "Adrian der Große" },
// { id: "2", name: "Tobi der Kleine" },
// { id: "3", name: "Lukas" },
// ];
// 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",
// parent_id: "6",
// member_count: 1,
// };
import Spinner from "../widgets/Spinner";
import { ErrorBox } from "../widgets/ErrorBox";
export default function GroupAddMembersPage() {
const intl = useIntlMessages();
......@@ -70,24 +49,36 @@ export function ServerSearchUserList({ group, members }: ServerSearchUserListPro
const intl = useIntlMessages();
const [searchQuery, setSearchQuery] = useState("");
const existingMemberIds = useMemo(() => new Set(members.map(m => m.id)), [members]);
const usersQuery = useSearchUsersQuery(group.id, searchQuery);
const foundUsers: ApiGroupUser[] = []; // TODO
const renderAddon = useCallback(
(props: UserListItemAddonProps) => <AddMemberAddon {...props} isMember={existingMemberIds.has(props.user.id)} />,
[existingMemberIds]
);
const listPlaceholder = usersQuery.isFetching ? (
<div className="flex flex-row gap-4 items-center justify-center">
<Spinner className="w-5 h-5" />
<Message id="groups.members.add.search-loading" />
</div>
) : usersQuery.error ? (
<ErrorBox message={intl("groups.members.add.search-error")} details={usersQuery.error.message} />
) : searchQuery.length > 0 ? (
<Message id="groups.members.add.search-no-results" />
) : (
<Message id="groups.members.add.empty" />
);
return (
<>
<Input
placeholder={intl("groups.members.add.search-placeholder")}
className="w-full mt-8 mb-4"
onChange={e => setSearchQuery(e.target.value)}
/>
<UserList
<SearchableUserList
title={intl("groups.members.add.list-title")}
users={foundUsers}
users={usersQuery.isFetching ? [] : (usersQuery.data ?? [])}
group={group}
emptyPlaceholderText={
searchQuery.length > 0 ? intl("groups.members.add.no-results") : intl("groups.members.add.empty")
}
itemAddon={p => <AddMemberAddon {...p} isMember={existingMemberIds.has(p.user.id)} />}
emptyPlaceholder={listPlaceholder}
itemAddon={renderAddon}
searchPlaceholder={intl("groups.members.add.search-placeholder")}
onSearchQueryChange={setSearchQuery}
/>
</>
);
......
......@@ -7,26 +7,14 @@ import XMark from "../../icons/XMark.svg?react";
import { STYLES } from "../../constants";
import Button from "../widgets/Button";
import type { MouseEvent } from "react";
import { useCallback, useState } from "react";
import { useCallback, useMemo, 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: 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",
// parent_id: "6",
// member_count: 1,
// };
import type { ApiGetGroupResponse, ApiGroup, ApiGroupUser } from "../../data/schemas";
import Fuse from "fuse.js";
export default function GroupManagementPage() {
const intl = useIntlMessages();
......@@ -34,24 +22,10 @@ export default function GroupManagementPage() {
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]
);
const renderUserList = useCallback((data: ApiGetGroupResponse) => {
const { members, ...group } = data;
return <LocalSearchUserList group={group} members={members} />;
}, []);
return (
<>
......@@ -76,6 +50,39 @@ export default function GroupManagementPage() {
);
}
interface LocalSearchUserListProps {
group: ApiGroup;
members: ApiGroupUser[];
}
function LocalSearchUserList({ group, members }: LocalSearchUserListProps) {
const intl = useIntlMessages();
const [searchQuery, setSearchQuery] = useState("");
const renderAddon = useCallback((props: UserListItemAddonProps) => <RemoveMemberAddon {...props} />, []);
const fuse = useMemo(() => new Fuse(members, { keys: ["name"] }), [members]);
const filteredUsers = useMemo(
() => (searchQuery.length > 0 ? fuse.search(searchQuery).map(result => result.item) : null),
[fuse, searchQuery]
);
const listPlaceholder =
searchQuery.length > 0 ? <Message id="groups.members.no-results" /> : <Message id="groups.members.empty" />;
return (
<SearchableUserList
title={intl("groups.members.list-title")}
searchPlaceholder={intl("groups.members.search-placeholder")}
emptyPlaceholder={listPlaceholder}
group={group}
users={filteredUsers ?? members}
itemAddon={renderAddon}
onSearchQueryChange={setSearchQuery}
/>
);
}
function RemoveMemberAddon({ user, group }: UserListItemAddonProps) {
const intl = useIntlMessages();
const [showRemovalConfirmation, setShowRemovalConfirmation] = useState(false);
......@@ -84,7 +91,7 @@ function RemoveMemberAddon({ user, group }: UserListItemAddonProps) {
const onConfirmClick = useCallback(() => {
removeMemberMutation.mutate();
}, []);
}, [removeMemberMutation]);
const onCancelClick = useCallback(() => {
setShowRemovalConfirmation(false);
......
import UserListItem from "./UserListItem";
import { GridTable, GridTableEmpty, GridTableRow, GridTableTitle } from "../GridTable";
import type { ApiGroup, ApiGroupUser } from "../../../data/schemas";
import { useMemo, useState } from "react";
import Fuse from "fuse.js";
import type { ChangeEvent } from "react";
import { useCallback } from "react";
import { Input } from "../../forms/Input";
export interface UserListItemAddonProps {
......@@ -12,13 +12,13 @@ export interface UserListItemAddonProps {
interface Props {
title: string;
emptyPlaceholderText: string;
emptyPlaceholder: string | React.ReactNode;
group: ApiGroup;
users: ApiGroupUser[];
itemAddon?: (data: UserListItemAddonProps) => React.ReactNode;
}
export function UserList({ title, emptyPlaceholderText, users, group, itemAddon }: Props) {
export function UserList({ title, emptyPlaceholder: emptyPlaceholderText, users, group, itemAddon }: Props) {
return (
<GridTable className="grid-cols-[auto_1fr_auto] gap-1.5 pb-1.5">
<GridTableTitle>{title}</GridTableTitle>
......@@ -34,30 +34,23 @@ export function UserList({ title, emptyPlaceholderText, users, group, itemAddon
type SearchableUserListProps = Omit<Props, "isFiltered"> & {
searchPlaceholder: string;
emptyPlaceholderTextSearch: string;
onSearchQueryChange: (query: string) => void;
};
export function SearchableUserList(props: SearchableUserListProps) {
const [searchQuery, setSearchQuery] = useState("");
export function SearchableUserList(allProps: SearchableUserListProps) {
const { searchPlaceholder, onSearchQueryChange, ...props } = allProps;
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]
const onInputChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
onSearchQueryChange(e.target.value);
},
[onSearchQueryChange]
);
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}
/>
<Input placeholder={searchPlaceholder} className="w-full mt-8 mb-4" onChange={onInputChange} />
<UserList {...props} />
</>
);
}
......@@ -3,7 +3,7 @@ import type { ApiResponse } from "./ApiRequests";
import ApiRequests from "./ApiRequests";
import Events from "./events/Events";
import { EventTypes } from "./events/eventTypes";
import type { ApiPatchMeRequest } from "./schemas";
import type { ApiGetDiscordResponse, ApiGetMeResponse, ApiPatchMeRequest } from "./schemas";
import { useDiscordStore } from "./state/discord";
import { useLoginStore } from "./state/login";
import { useUserStore } from "./state/user";
......@@ -105,7 +105,7 @@ export default class ApiActions {
);
}
public static updateSelf(data: ApiPatchMeRequest): void {
static updateSelf(data: ApiPatchMeRequest): void {
Events.emit(EventTypes.UPDATE_SELF);
this.invokeApi(
() => ApiRequests.patchMe(data),
......@@ -143,7 +143,7 @@ export default class ApiActions {
);
}
public static removeDiscordConnection(): void {
static removeDiscordConnection(): void {
Events.emit(EventTypes.REMOVE_DISCORD_CONNECTION);
this.invokeApi(
() => ApiRequests.deleteDiscord(),
......@@ -155,7 +155,7 @@ export default class ApiActions {
);
}
public static exchangeDiscordCode(code: string, state: string): void {
static exchangeDiscordCode(code: string, state: string): void {
Events.emit(EventTypes.DISCORD_EXCHANGE_CODE);
this.invokeApi(
() => ApiRequests.postAuthDiscordToken({ code, state }),
......@@ -190,7 +190,7 @@ export default class ApiActions {
);
}
public static logout(): void {
static logout(): void {
Events.emit(EventTypes.LOGOUT);
this.invokeApi(
() => ApiRequests.postLogout(),
......@@ -210,7 +210,7 @@ export default class ApiActions {
);
}
public static forceLogout(): void {
static forceLogout(): void {
this.clearStores();
Events.emit(EventTypes.LOGOUT_SUCCESS);
Events.emit(EventTypes.LOGOUT_COMPLETE);
......@@ -223,8 +223,8 @@ export default class ApiActions {
): void {
const run = async () => {
const res = await start();
if (res.error) {
const err = new Error(res.data.error);
if (!res.ok) {
const err = new Error(res.data.message);
Events.emit(EventTypes.API_CALL_FAIL, {
error: err,
response: res,
......
......@@ -11,6 +11,7 @@ import {
ApiPostAuthDiscordTokenResponseSchema,
ApiGetGroupsResponseSchema,
ApiGetGroupResponseSchema,
ApiGetUsersResponseSchema,
} from "./schemas";
import type {
ApiDeleteDiscordResponse,
......@@ -18,6 +19,7 @@ import type {
ApiGetGroupResponse,
ApiGetGroupsResponse,
ApiGetMeResponse,
ApiGetUsersResponse,
ApiPatchMeRequest,
ApiPatchMeResponse,
ApiPostAuthDiscordTokenRequest,
......@@ -45,11 +47,11 @@ export interface ApiErrorResponse {
}
export class ApiError extends Error {
public constructor(
public readonly method: string,
public readonly route: string,
public readonly status: number,
public readonly response: ApiErrorResponse
constructor(
readonly method: string,
readonly route: string,
readonly status: number,
readonly response: ApiErrorResponse
) {
let message = `HTTP ${status} @ ${method} ${route}: ${response.message}`;
if (response.issues) {
......@@ -70,47 +72,57 @@ export interface RequestOptions {
}
export default class ApiRequests {
public static async getMe(): Promise<ApiResponse<ApiGetMeResponse>> {
static async getMe(): Promise<ApiResponse<ApiGetMeResponse>> {
return this.request("GET", "/me", ApiGetMeResponseSchema);
}
public static async patchMe(data: ApiPatchMeRequest): Promise<ApiResponse<ApiPatchMeResponse>> {
static async patchMe(data: ApiPatchMeRequest): Promise<ApiResponse<ApiPatchMeResponse>> {
return this.request("PATCH", "/me", ApiPatchMeResponseSchema, {
jsonBody: data,
});
}
public static async getDiscord(): Promise<ApiResponse<ApiGetDiscordResponse>> {
static async getDiscord(): Promise<ApiResponse<ApiGetDiscordResponse>> {
return this.request("GET", "/discord", ApiGetDiscordResponseSchema);
}
public static async deleteDiscord(): Promise<ApiResponse<ApiDeleteDiscordResponse>> {
static async deleteDiscord(): Promise<ApiResponse<ApiDeleteDiscordResponse>> {
return this.request("DELETE", "/discord", ApiDeleteDiscordResponseSchema);
}
public static async getGroups(): Promise<ApiGetGroupsResponse> {
static async getGroups(): Promise<ApiGetGroupsResponse> {
return this.tryRequest("GET", "/groups", ApiGetGroupsResponseSchema);
}
public static async getGroup(groupId: string): Promise<ApiGetGroupResponse> {
static async getGroup(groupId: string): Promise<ApiGetGroupResponse> {
return this.tryRequest("GET", `/groups/${groupId}`, ApiGetGroupResponseSchema);
}
public static async addGroupMember(groupId: string, userId: string): Promise<void> {
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> {
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>> {
static async searchUsers(groupId: string, query: string): Promise<ApiGetUsersResponse> {
return this.tryRequest("GET", `/users`, ApiGetUsersResponseSchema, {
params: new URLSearchParams({
group_id: groupId,
q: query,
include_members: "true",
}),
});
}
static async postAuthRubToken(data: ApiPostAuthRubTokenRequest): Promise<ApiResponse<ApiPostAuthRubResponse>> {
return this.request("POST", "/auth/rub/token", ApiPostAuthRubResponseSchema, {
jsonBody: data,
});
}
public static async postAuthDiscordToken(
static async postAuthDiscordToken(
data: ApiPostAuthDiscordTokenRequest
): Promise<ApiResponse<ApiPostAuthDiscordTokenResponse>> {
return this.request("POST", "/auth/discord/token", ApiPostAuthDiscordTokenResponseSchema, {
......@@ -118,7 +130,7 @@ export default class ApiRequests {
});
}
public static async postLogout(): Promise<ApiResponse<void>> {
static async postLogout(): Promise<ApiResponse<void>> {
return this.request("POST", "/logout", null);
}
......
......@@ -26,19 +26,19 @@ const TAG = "Events";
export default class Events {
private static listeners: Map<string, EventCallback> = new Map();
public static on(listener: EventCallback): EventToken {
static on(listener: EventCallback): EventToken {
const id = uniqueId("evt");
this.listeners.set(id, listener);
return id;
}
public static off(id: EventToken): void {
static off(id: EventToken): void {
this.listeners.delete(id);
}
public static emit(type: ArglessEventTypes): void;
public static emit<T extends ArgfulEventTypes>(type: T, args: EventDataMap[T]): void;
public static emit<T extends EventTypesType>(type: T, args?: EventDataMap[T]): void {
static emit(type: ArglessEventTypes): void;
static emit<T extends ArgfulEventTypes>(type: T, args: EventDataMap[T]): void;
static emit<T extends EventTypesType>(type: T, args?: EventDataMap[T]): void {
// @ts-expect-error idek
const data: EventCallbackDataMap[T] = {
type,
......
......@@ -23,3 +23,9 @@ export const useRemoveGroupMemberMutation = (groupId: string, userId: string) =>
useMutation({
mutationFn: useCallback(() => ApiRequests.removeGroupMember(groupId, userId), [groupId, userId]),
});
export const useSearchUsersQuery = (groupId: string, query: string) =>
useQuery({
queryKey: ["users", groupId, query],
queryFn: useCallback(() => ApiRequests.searchUsers(groupId, query), [groupId, query]),
});
......@@ -90,6 +90,11 @@ export type ApiGetGroupResponse = InferOutput<typeof ApiGetGroupResponseSchema>;
// 204 No Content
// --- GET /users ---
export const ApiGetUsersResponseSchema = array(ApiGroupUserSchema);
export type ApiGetUsersResponse = InferOutput<typeof ApiGetUsersResponseSchema>;
// --- POST /auth/rub/token ---
export interface ApiPostAuthRubTokenRequest {
......
......@@ -34,7 +34,7 @@ export function startOAuthFlow(type: OAuthType): void {
let awaitingReply = true;
const healthcheckInterval = setInterval(() => {
if (popup!.closed && awaitingReply) {
if (popup.closed && awaitingReply) {
clearInterval(healthcheckInterval);
emitError(type, new Error(`Popup closed before exchanging data`));
}
......@@ -51,7 +51,7 @@ export function startOAuthFlow(type: OAuthType): void {
) {
return;
}
popup!.postMessage(
popup.postMessage(
{
messageId: e.data.messageId,
type: `oauth-confirm:${type}`,
......
......@@ -19,5 +19,5 @@ export function generateInitials(name: string): string {
const initialsList = name.split(" ").map(part => part.charAt(0).toUpperCase());
return initialsList.length > 1
? `${initialsList[0]}${initialsList[initialsList.length - 1]}`
: initialsList[0] ?? "?";
: (initialsList[0] ?? "?");
}
......@@ -19,12 +19,12 @@
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"sourceMap": true,
"sourceMap": true
},
"include": ["src"],
"references": [
{
"path": "./tsconfig.node.json",
},
],
"path": "./tsconfig.node.json"
}
]
}