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 (3)
Showing
with 2251 additions and 1497 deletions
This diff is collapsed.
......@@ -12,37 +12,38 @@
},
"dependencies": {
"classnames": "^2.5.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-error-boundary": "^4.0.12",
"react-hook-form": "^7.49.3",
"react-intl": "^6.6.1",
"react-router-dom": "^6.21.3",
"react-select": "^5.8.0",
"valibot": "^0.26.0",
"zustand": "^4.4.7"
"fuse.js": "^7.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-error-boundary": "^5.0.0",
"react-hook-form": "^7.54.2",
"react-intl": "^7.0.4",
"react-router": "^7.1.1",
"react-select": "^5.9.0",
"valibot": "^0.42.1",
"zustand": "^5.0.2"
},
"devDependencies": {
"@tailwindcss/nesting": "^0.0.0-insiders.565cd3e",
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",
"@typescript-eslint/eslint-plugin": "^6.19.0",
"@typescript-eslint/parser": "^6.19.0",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.17",
"eslint": "^8.56.0",
"eslint-plugin-import": "^2.29.1",
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"eslint": "^8.57.1",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-prefer-arrow": "^1.2.3",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0",
"postcss": "^8.4.33",
"postcss-nesting": "^12.0.2",
"prettier": "^3.2.4",
"rollup-plugin-visualizer": "^5.12.0",
"tailwindcss": "^3.4.1",
"typescript": "^5.3.3",
"unplugin-inject-preload": "^2.0.0",
"vite": "^5.0.11",
"vite-plugin-svgr": "^4.2.0"
"eslint-plugin-react": "^7.37.3",
"eslint-plugin-react-hooks": "^5.1.0",
"postcss": "^8.4.49",
"postcss-nesting": "^13.0.1",
"prettier": "^3.4.2",
"rollup-plugin-visualizer": "^5.13.1",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.2",
"unplugin-inject-preload": "^3.0.0",
"vite": "^5.4.11",
"vite-plugin-svgr": "^4.3.0"
}
}
import { Navigate, useLocation } from "react-router-dom";
import { Navigate, useLocation } from "react-router";
import { useLoginStore } from "../data/state/login";
import { useEffect } from "react";
import { useShallow } from "zustand/react/shallow";
interface Props {
element: JSX.Element;
fallback?: JSX.Element;
element: React.ReactNode;
fallback?: React.ReactNode;
}
export default function AuthGuard({ element, fallback }: Props) {
const [isLoggedIn, setRedirectTo] = useLoginStore(s => [s.isLoggedIn, s.setRedirectTo]);
const [isLoggedIn, setRedirectTo] = useLoginStore(useShallow(s => [s.isLoggedIn, s.setRedirectTo]));
const location = useLocation();
useEffect(() => {
......
......@@ -4,7 +4,7 @@ import Icon from "../icons/WheelchairMove.svg?react";
import Button from "./widgets/Button";
import { useEffect, type ErrorInfo } from "react";
import { STYLES } from "../constants";
import { useRouteError } from "react-router-dom";
import { useRouteError } from "react-router";
const TAG = "RootErrorBoundary";
......
import { useFormContext } from "react-hook-form";
import { getFormItemErrorMessage } from "../../utils/utils";
import { STYLES } from "../../constants";
export type FormInputType = React.HTMLInputTypeAttribute | "textarea";
import type { InputType } from "./Input";
import { Input } from "./Input";
interface Props {
name: string;
type?: FormInputType;
type?: InputType;
value?: string;
required?: boolean;
placeholder?: string;
......@@ -24,32 +24,11 @@ export default function FormInput({ name, type, value, required, placeholder, cl
setValueAs: (v: string | null | undefined) => v?.trim(),
});
const classes =
"block w-full rounded overflow-hidden transition-colors " +
"bg-white hover:bg-gray-100 dark:bg-gray-900 dark:hover:bg-slate-800 border border-slate-300 dark:border-slate-700";
const inputClasses = "block w-full px-3 py-2 bg-transparent";
const formElement =
type === "textarea" ? (
<textarea defaultValue={value} placeholder={placeholder} className={inputClasses} {...registration} />
) : (
<input
type={type ?? "text"}
defaultValue={value}
placeholder={placeholder}
className={inputClasses}
{...registration}
/>
);
return (
<div className={className}>
<div className={classes}>
<div className="flex flex-row items-center">
{formElement}
{children}
</div>
</div>
<Input type={type} defaultValue={value} placeholder={placeholder} {...registration}>
{children}
</Input>
{errorMessage && <div className={`${STYLES.ERROR_COLOR} text-sm px-2`}>{errorMessage}</div>}
</div>
);
......
......@@ -18,7 +18,7 @@ interface Props {
disabled?: boolean;
}
const FormSelect = ({ name, options, multiple, required, value, disabled }: Props): JSX.Element => {
export default function FormSelect({ name, options, multiple, required, value, disabled }: Props) {
const classes =
"block w-full rounded border " +
"bg-white hover:bg-gray-100 dark:bg-gray-900 dark:hover:bg-slate-800 border-slate-300 dark:border-slate-700";
......@@ -57,6 +57,4 @@ const FormSelect = ({ name, options, multiple, required, value, disabled }: Prop
}}
/>
);
};
export default FormSelect;
}
import classNames from "classnames";
// Copied from React but without the `(string & {})` at the end
type HTMLInputTypeAttribute =
| "button"
| "checkbox"
| "color"
| "date"
| "datetime-local"
| "email"
| "file"
| "hidden"
| "image"
| "month"
| "number"
| "password"
| "radio"
| "range"
| "reset"
| "search"
| "submit"
| "tel"
| "text"
| "time"
| "url"
| "week";
type InputOrTextareaProps =
| (React.InputHTMLAttributes<HTMLInputElement> & { type?: HTMLInputTypeAttribute })
| (React.TextareaHTMLAttributes<HTMLTextAreaElement> & { type: "textarea" });
export type InputType = NonNullable<InputOrTextareaProps["type"]>;
type Props = InputOrTextareaProps & {
className?: string;
children?: React.ReactNode;
};
export function Input({ className, children, ...props }: Props) {
const classes =
"block w-full rounded overflow-hidden transition-colors " +
"bg-white hover:bg-gray-100 dark:bg-gray-900 dark:hover:bg-slate-800 border border-slate-300 dark:border-slate-700";
const inputClasses = "block w-full px-3 py-2 bg-transparent";
const formElement =
props.type === "textarea" ? (
<textarea className={inputClasses} {...props} />
) : (
<input type={props.type ?? "text"} className={inputClasses} {...props} />
);
return (
<div className={classNames(classes, className)}>
<div className="flex flex-row items-center">
{formElement}
{children}
</div>
</div>
);
}
......@@ -8,6 +8,7 @@ interface Props {
vars?: Record<string, React.ReactNode | PrimitiveType | FormatXMLElementFn<React.ReactNode, React.ReactNode>>;
}
// NOTE: Key errors are due to https://github.com/formatjs/formatjs/issues/4782
export default function Message({ id, vars }: Props) {
return (
<FormattedMessage
......
......@@ -3,7 +3,7 @@ import Header from "./Header";
import Sidebar from "./Sidebar";
import MobileNavigation from "./MobileNavigation";
import Shells from "../shells/Shells";
import { Outlet } from "react-router-dom";
import { Outlet } from "react-router";
import LoadingPage from "../pages/LoadingPage";
import { Suspense } from "react";
......
import { lazy } from "react";
import { Route, RouterProvider, createBrowserRouter, createRoutesFromElements } from "react-router-dom";
import { Route, RouterProvider, createBrowserRouter, createRoutesFromElements } from "react-router";
import { ReactRouterErrorBoundary } from "../RootErrorBoundary";
import App from "./App";
import LoginPage from "../pages/LoginPage";
......
import { Link } from "react-router-dom";
import { Link } from "react-router";
import { STYLES, Z_INDEXES } from "../../constants";
import type { WithClassName } from "../../utils/types";
import classNames from "classnames";
......@@ -8,13 +8,15 @@ import ThemeToggle from "../widgets/ThemeToggle";
export default function Header() {
return (
<div className="max-lg:fixed inset-x-0 top-0 bg-body-light dark:bg-body-dark lg:contents" style={{
zIndex: Z_INDEXES.HEADER,
}}>
<div
className="max-lg:fixed inset-x-0 top-0 bg-body-light dark:bg-body-dark lg:contents"
style={{
zIndex: Z_INDEXES.HEADER,
}}>
<HeaderLogo className={`max-lg:hidden border-b ${STYLES.BORDER_COLOR}`} />
<header className={`flex flex-row items-center border-b ${STYLES.BORDER_COLOR} pr-4 sm:pr-8`}>
<HeaderLogo className="max-sm:hidden lg:hidden ml-5" />
<Link to="/" className="my-6 mx-4 sm:m-8" unstable_viewTransition>
<Link to="/" className="my-6 mx-4 sm:m-8" viewTransition>
<h1 className="text-2xl font-bold">FSI Self-Service</h1>
</Link>
<div className="max-lg:hidden grow flex flex-row items-center justify-end gap-8">
......@@ -32,7 +34,7 @@ export default function Header() {
export function HeaderLogo({ className }: WithClassName) {
return (
<div className={classNames(`grid place-items-center`, className)}>
<Link to="/" unstable_viewTransition>
<Link to="/" viewTransition>
<img src="/logo128.png" className="w-14 h-14 object-contain rounded" />
</Link>
</div>
......
......@@ -6,10 +6,11 @@ import HR from "../widgets/HR";
import LanguageSelect from "../widgets/LanguageSelect";
import classNames from "classnames";
import ThemeToggle from "../widgets/ThemeToggle";
import { Link } from "react-router-dom";
import { Link } from "react-router";
import { useShallow } from "zustand/react/shallow";
export default function MobileNavigation() {
const [isVisible, setVisible] = useNavigationStore(s => [s.isVisible, s.setVisible]);
const [isVisible, setVisible] = useNavigationStore(useShallow(s => [s.isVisible, s.setVisible]));
const sidebarClasses =
// Layout
......@@ -43,7 +44,7 @@ export default function MobileNavigation() {
zIndex: Z_INDEXES.MOBILE_NAV,
}}>
<HeaderLogo className="m-4" />
<Link to="/" unstable_viewTransition>
<Link to="/" viewTransition>
<div className="font-bold text-xl text-center">FSI Self-Service</div>
<div className={`${STYLES.MUTED_COLOR} text-center`}>at your service</div>
</Link>
......
import classNames from "classnames";
import { NavLink } from "react-router-dom";
import { NavLink } from "react-router";
import type { WithClassName } from "../../utils/types";
import ArrowUpRightFromSquare from "../../icons/ArrowUpRightFromSquare.svg?react";
import useIntlMessages from "../i18n/useIntlMessages";
......@@ -42,7 +42,7 @@ function NavigationLink({ name, to }: NavigationLinkProps) {
</a>
) : (
<NavLink
unstable_viewTransition
viewTransition
to={to}
className={({ isActive, isPending }) =>
classNames("block p-5", {
......
......@@ -2,9 +2,14 @@ import classNames from "classnames";
import Bars from "../../icons/Bars.svg?react";
import XMark from "../../icons/XMark.svg?react";
import { useNavigationStore } from "../../data/state/navigation";
import { useShallow } from "zustand/react/shallow";
export default function NavigationHamburger({ className, children, ...props }: JSX.IntrinsicElements["button"]) {
const [isVisible, toggleVisibility] = useNavigationStore(s => [s.isVisible, s.toggleVisibility]);
export default function NavigationHamburger({
className,
children,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement>) {
const [isVisible, toggleVisibility] = useNavigationStore(useShallow(s => [s.isVisible, s.toggleVisibility]));
const classes = classNames("grid grid-cols-1 p-2 border border-slate-400 dark:border-slate-500 rounded", className);
......
import { Link, useParams } from "react-router-dom";
import { Link, useParams } from "react-router";
import { H2 } from "../widgets/typography/Heading";
import type { ApiGroup } from "../../data/schemas";
import UserList, { type GroupMember } from "../widgets/group/UserList";
import UserList from "../widgets/group/UserList";
import type { UserListItemAddonProps, GroupMember } 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";
const mockMembers: GroupMember[] = [
{ id: "1", name: "Adrian der Große" },
......@@ -20,7 +26,8 @@ const mockGroup: ApiGroup = {
memberCount: 1,
};
export default function GroupMembersManagementPage() {
// TODO Add search bar
export default function GroupManagementPage() {
const { groupId } = useParams<{ groupId: string }>();
const group = {
......@@ -34,7 +41,7 @@ export default function GroupMembersManagementPage() {
<Link
to="/groups"
className={`w-8 h-8 grid justify-center items-center rounded-full ${STYLES.CLICKABLE} shrink-0`}
unstable_viewTransition>
viewTransition>
<ChevronRight className="h-5 rotate-180 fill-current relative top-0.5" />
</Link>
<H2 className="break-words !m-0">{group.name}</H2>
......@@ -43,7 +50,93 @@ export default function GroupMembersManagementPage() {
Add Members
</Button>
</div>
<UserList users={mockMembers} group={group} />
<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}
group={group}
isFiltered={searchQuery.length > 0}
itemAddon={p => <RemoveMemberAddon {...p} />}
/>
</>
);
}
function RemoveMemberAddon({ user, group }: UserListItemAddonProps) {
const [isLoading, setLoading] = useState(false);
const [showRemovalConfirmation, setShowRemovalConfirmation] = useState(false);
const onRemoveConfirmClick = useCallback(() => {
setLoading(true);
// TODO Handle Removal
}, []);
const onRemoveCancelClick = useCallback(() => {
setShowRemovalConfirmation(false);
}, []);
const onRemoveClick = useCallback(
(e: MouseEvent) => {
if (isLoading) {
return;
}
if (e.getModifierState("Shift")) {
// Skip confirmation prompt
setShowRemovalConfirmation(true);
onRemoveConfirmClick();
return;
}
setShowRemovalConfirmation(!showRemovalConfirmation);
},
[isLoading, showRemovalConfirmation, onRemoveConfirmClick]
);
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`}>
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}>
Nevermind
</Button>
<Button kind="danger" outlined loading={isLoading} onClick={onRemoveConfirmClick}>
Remove
</Button>
</div>
</div>
) : null;
return (
<>
<Button outlined kind="danger" size="small" disabled={isLoading} onClick={onRemoveClick} className="rounded-full">
<XMark className="w-4 h-4 fill-current" />
</Button>
{cancellationConfirmation}
</>
);
}
import { useNavigate, useSearchParams } from "react-router-dom";
import { useNavigate, useSearchParams } from "react-router";
import { EventTypes } from "../../data/events/eventTypes";
import useEvents from "../../data/events/useEvents";
import { OAuthType, exchangeOAuthTokens, startOAuthFlow } from "../../utils/oauth";
......@@ -10,19 +10,22 @@ import Message from "../i18n/Message";
import Button from "../widgets/Button";
import { useLoginStore } from "../../data/state/login";
import LoadingIndicator from "../widgets/LoadingIndicator";
import { useShallow } from "zustand/react/shallow";
export default function LoginPage() {
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [isExchangingToken, setIsExchangingToken] = useState<boolean>(false);
const [isLoggedIn, redirectTo, setRedirectTo] = useLoginStore(s => [s.isLoggedIn, s.redirectTo, s.setRedirectTo]);
const [isLoggedIn, redirectTo, setRedirectTo] = useLoginStore(
useShallow(s => [s.isLoggedIn, s.redirectTo, s.setRedirectTo])
);
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const redirectAfterLogin = () => {
setRedirectTo(null);
navigate(redirectTo ?? "/", {
void navigate(redirectTo ?? "/", {
replace: true,
unstable_viewTransition: true,
viewTransition: true,
});
};
......
import { useNavigate } from "react-router-dom";
import { useNavigate } from "react-router";
import ApiActions from "../../data/ApiActions";
import { useEffect } from "react";
import useEvents from "../../data/events/useEvents";
......@@ -14,8 +14,8 @@ export default function LogoutPage() {
case EventTypes.LOGOUT_SUCCESS:
case EventTypes.LOGOUT_FAIL:
// Ignore errors, ApiActions still removed the token from storage
navigate("/", {
unstable_viewTransition: true,
void navigate("/", {
viewTransition: true,
});
break;
}
......
import { useEffect, useState } from "react";
import { H2 } from "../widgets/typography/Heading";
import P from "../widgets/typography/Paragraph";
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import { useNavigate, useParams, useSearchParams } from "react-router";
import uniqueId from "../../utils/uniqueId";
enum State {
......@@ -42,9 +42,9 @@ export default function OAuthCallbackPage() {
const timeout = setTimeout(() => {
setPageState(State.TIMEOUT);
navigate(`/login?${searchParams}`, {
void navigate(`/login?${searchParams}`, {
replace: true,
unstable_viewTransition: true,
viewTransition: true,
});
}, 2000);
......
import { useNavigate } from "react-router-dom";
import { useNavigate } from "react-router";
import useEvents from "../../data/events/useEvents";
import { EventTypes } from "../../data/events/eventTypes";
import ApiActions from "../../data/ApiActions";
......@@ -12,8 +12,8 @@ export default function ApiErrorShell() {
// If the stored token has expired, the user will have to log in again
if (data.response.status === 401) {
ApiActions.forceLogout();
navigate("/login", {
unstable_viewTransition: true,
void navigate("/login", {
viewTransition: true,
});
}
break;
......
import { useEffect } from "react";
import { useLocation } from "react-router-dom";
import { useLocation } from "react-router";
import { useNavigationStore } from "../../data/state/navigation";
import { useShallow } from "zustand/react/shallow";
export default function NavigationDrawerShell() {
const [isNavVisible, setNavVisible] = useNavigationStore(s => [s.isVisible, s.setVisible]);
const [isNavVisible, setNavVisible] = useNavigationStore(useShallow(s => [s.isVisible, s.setVisible]));
const location = useLocation();
useEffect(() => {
......