This commit is contained in:
root
2025-10-28 16:07:38 +00:00
parent e96cd63f33
commit 4eea4b23bd
16 changed files with 907 additions and 230 deletions
+17
View File
@@ -0,0 +1,17 @@
import React from 'react';
import { renderToString } from 'react-dom/server';
import pkg from '@material-tailwind/react';
const { Select, Option } = pkg;
const element = React.createElement(Select, {
label: 'Test',
value: 'b',
selected: (el) => el?.props.children,
onChange: () => {},
children: [
React.createElement(Option, { key: 'a', value: 'a' }, 'Alpha'),
React.createElement(Option, { key: 'b', value: 'b' }, 'Beta')
]
});
console.log(renderToString(element));
+14
View File
@@ -0,0 +1,14 @@
export const AVATAR_COLORS = [
"bg-arcticBlue-600",
"bg-copperRust-500",
"bg-sunsetCoral-600",
"bg-mintTea-400",
"bg-lavenderSmoke-500",
"bg-emeraldMist-500",
"bg-roseQuartz-500",
"bg-auroraTeal-500",
"bg-citrusPunch-500",
"bg-mossGreen-400"
];
export const DEFAULT_AVATAR_COLOR = "bg-mossGreen-500";
+79 -123
View File
@@ -9,18 +9,13 @@ import {
Button,
Spinner,
CardFooter,
Tabs,
TabsHeader,
Tab,
Switch,
Tooltip,
Avatar,
Checkbox
Select,
Option
} from "@material-tailwind/react";
import {
HomeIcon,
ChatBubbleLeftEllipsisIcon,
Cog6ToothIcon,
PencilIcon,
} from "@heroicons/react/24/outline";
import { Link } from "react-router-dom";
@@ -28,22 +23,9 @@ import { Link } from "react-router-dom";
import { useToast } from "@/components/ToastProvider.jsx";
import { ProfileInfoCard } from "@/widgets/cards";
import { platformSettingsData, projectsData } from "@/data";
import { AVATAR_COLORS } from "@/data/avatarColors.js";
import { useMaintenance } from "@/components/MaintenanceProvider.jsx";
const DEFAULT_AVATAR = { background: "bg-blue-gray-500", text: "text-white" };
const AVATAR_COLORS = [
'bg-arcticBlue-600',
'bg-copperRust-500',
'bg-sunsetCoral-600',
'bg-mintTea-400',
'bg-lavenderSmoke-500',
'bg-emeraldMist-500',
'bg-roseQuartz-500',
'bg-auroraTeal-500',
'bg-citrusPunch-500',
'bg-mossGreen-400'
];
const _ = AVATAR_COLORS.join(" ");
@@ -97,8 +79,8 @@ export function UserDetails() {
const [availableGroups, setAvailableGroups] = useState([]);
const [groupsLoading, setGroupsLoading] = useState(false);
const [groupsError, setGroupsError] = useState("");
const [selectedGroupIds, setSelectedGroupIds] = useState([]);
const [savingGroups, setSavingGroups] = useState(false);
const [selectedGroupId, setSelectedGroupId] = useState(null);
const [savingGroup, setSavingGroup] = useState(false);
const maintenanceActive = Boolean(maintenance?.active);
@@ -203,109 +185,95 @@ export function UserDetails() {
fetchAvailableGroups();
}, [fetchAvailableGroups]);
const originalGroupIds = useMemo(() => {
if (!user || !Array.isArray(user.groups)) {
return [];
const originalGroupId = useMemo(() => {
if (!user || !Array.isArray(user.groups) || user.groups.length === 0) {
return null;
}
return user.groups
const firstValid = user.groups
.map((group) => Number(group.id))
.filter((id) => Number.isFinite(id) && id > 0);
.find((id) => Number.isFinite(id) && id > 0);
return Number.isFinite(firstValid) ? firstValid : null;
}, [user]);
useEffect(() => {
setSelectedGroupIds([...originalGroupIds]);
}, [originalGroupIds]);
setSelectedGroupId(originalGroupId);
}, [originalGroupId]);
const hasGroupChanges = useMemo(() => {
const normalize = (ids) =>
Array.from(
new Set(
ids
.map((id) => Number(id))
.filter((id) => Number.isFinite(id) && id > 0)
)
).sort((a, b) => a - b);
const next = normalize(selectedGroupIds);
const baseline = normalize(originalGroupIds);
if (next.length !== baseline.length) {
return true;
if (!hasLoaded) {
return false;
}
return originalGroupId !== selectedGroupId;
}, [originalGroupId, selectedGroupId, hasLoaded]);
return next.some((value, index) => value !== baseline[index]);
}, [selectedGroupIds, originalGroupIds]);
const handleToggleGroup = useCallback((groupId) => {
const numeric = Number(groupId);
if (!Number.isFinite(numeric) || numeric <= 0) {
const handleGroupChange = useCallback((value) => {
if (!value) {
setSelectedGroupId(null);
return;
}
setSelectedGroupIds((prev) => {
if (prev.includes(numeric)) {
return prev.filter((id) => id !== numeric);
}
return [...prev, numeric];
});
const numeric = Number(value);
setSelectedGroupId(Number.isFinite(numeric) && numeric > 0 ? numeric : null);
}, []);
const handleResetGroups = useCallback(() => {
setSelectedGroupIds([...originalGroupIds]);
}, [originalGroupIds]);
const handleResetGroup = useCallback(() => {
setSelectedGroupId(originalGroupId);
}, [originalGroupId]);
const handleSaveGroups = useCallback(async () => {
if (!user) {
const handleSaveGroup = useCallback(async () => {
if (!user || !hasGroupChanges) {
return;
}
const payloadGroups = Array.from(
new Set(
selectedGroupIds
.map((id) => Number(id))
.filter((id) => Number.isFinite(id) && id > 0)
)
);
const payloadGroups = selectedGroupId ? [selectedGroupId] : [];
setSavingGroups(true);
setSavingGroup(true);
setGroupsError("");
try {
const response = await axios.put(`/api/users/${user.id}/groups`, { groupIds: payloadGroups });
const updatedUser = mapUser(response.data?.item || response.data?.user);
setUser(updatedUser);
setSelectedGroupIds(
Array.isArray(updatedUser.groups)
? updatedUser.groups
.map((group) => Number(group.id))
.filter((id) => Number.isFinite(id) && id > 0)
: []
);
const nextGroupId = Array.isArray(updatedUser.groups) && updatedUser.groups.length > 0
? Number(updatedUser.groups[0].id)
: null;
setSelectedGroupId(Number.isFinite(nextGroupId) && nextGroupId > 0 ? nextGroupId : null);
showToast({
variant: "success",
title: "Benutzergruppen aktualisiert",
title: "Globale Rolle aktualisiert",
description: "Die Gruppenzuordnung wurde gespeichert."
});
} catch (err) {
const serverError = err.response?.data?.error;
let message = err.message || "Benutzergruppen konnten nicht aktualisiert werden.";
let message = err.message || "Die globale Rolle konnte nicht aktualisiert werden.";
if (serverError === "INVALID_USER_ID") {
message = "Die Benutzer-ID ist ungültig.";
} else if (serverError === "USER_NOT_FOUND") {
message = "Der Benutzer konnte nicht gefunden werden.";
} else if (serverError === "GROUP_NOT_FOUND") {
message = "Mindestens eine ausgewählte Gruppe existiert nicht mehr.";
message = "Die ausgewählte Gruppe existiert nicht mehr.";
} else if (typeof serverError === "string" && serverError.length > 0) {
message = serverError;
}
setGroupsError(message);
showToast({
variant: "error",
title: "Benutzergruppen",
title: "Globale Rolle",
description: message
});
} finally {
setSavingGroups(false);
setSavingGroup(false);
}
}, [user, selectedGroupIds, showToast]);
}, [user, selectedGroupId, hasGroupChanges, showToast]);
const selectedGroupLabel = useMemo(() => {
if (!selectedGroupId) {
return null;
}
const match = availableGroups.find((group) => group.id === selectedGroupId);
return match ? match.name : null;
}, [availableGroups, selectedGroupId]);
const selectValue = selectedGroupId ? String(selectedGroupId) : "";
const selectDisabled = maintenanceActive || savingGroup || !user || groupsLoading;
const avatarLabel = useMemo(() => {
const source = (user?.username || user?.email || "").trim();
@@ -406,7 +374,7 @@ export function UserDetails() {
<div id="platform" className="flex flex-col gap-4">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<Typography variant="h6" color="blue-gray">
Benutzergruppen
Globale Rolle
</Typography>
<div className="flex items-center gap-2">
<Button
@@ -414,8 +382,8 @@ export function UserDetails() {
size="sm"
color="blue-gray"
className="normal-case"
onClick={handleResetGroups}
disabled={!hasGroupChanges || savingGroups || !user}
onClick={handleResetGroup}
disabled={!hasGroupChanges || savingGroup || !user}
>
Änderungen verwerfen
</Button>
@@ -423,10 +391,10 @@ export function UserDetails() {
size="sm"
color="green"
className="normal-case"
onClick={handleSaveGroups}
disabled={maintenanceActive || savingGroups || !hasGroupChanges || !user}
onClick={handleSaveGroup}
disabled={maintenanceActive || savingGroup || !hasGroupChanges || !user}
>
{savingGroups ? "Speichert ..." : "Speichern"}
{savingGroup ? "Speichert ..." : "Speichern"}
</Button>
</div>
</div>
@@ -450,43 +418,31 @@ export function UserDetails() {
Es sind noch keine Benutzergruppen vorhanden.
</Typography>
) : (
<div className="flex flex-col gap-3">
{availableGroups.map((group) => {
const checked = selectedGroupIds.includes(group.id);
return (
<label
key={group.id}
className={`flex items-start justify-between gap-3 rounded-lg border px-4 py-3 transition ${
checked ? "border-auroraTeal-500 bg-auroraTeal-500/10" : "border-blue-gray-100 bg-white"
} ${maintenanceActive ? "opacity-70" : ""}`}
>
<div className="flex items-start gap-3">
<Checkbox
ripple={false}
className="mt-1"
checked={checked}
onChange={() => handleToggleGroup(group.id)}
disabled={maintenanceActive || savingGroups || !user}
/>
<div>
<Typography variant="small" className="font-semibold text-blue-gray-900">
{group.name}
</Typography>
{group.description && (
<Typography variant="small" className="text-xs text-stormGrey-500">
{group.description}
</Typography>
)}
</div>
</div>
<Typography variant="small" className="text-xs font-medium text-stormGrey-500">
Mitglieder: {group.memberCount}
</Typography>
</label>
);
})}
<div className="flex flex-col gap-3 sm:flex-row sm:items-end">
<div className="sm:flex-1">
<Select
label="Benutzergruppe wählen"
value={selectValue}
onChange={handleGroupChange}
disabled={selectDisabled}
variant="outlined"
>
{availableGroups.map((group) => (
<Option key={group.id} value={String(group.id)}>
{group.name}
</Option>
))}
</Select>
</div>
</div>
)}
{!(groupsLoading && availableGroups.length === 0) && (
<Typography variant="small" className="text-sm text-stormGrey-500">
{selectedGroupLabel
? `Aktuell zugewiesene Gruppe: ${selectedGroupLabel}`
: "Dem Benutzer ist aktuell keine Gruppe zugewiesen."}
</Typography>
)}
</div>
</div>
<div className="px-4 pb-4">
+102 -2
View File
@@ -9,8 +9,7 @@ import {
Button,
Select,
Option,
Input,
useSelect
Input
} from "@material-tailwind/react";
import { PaginationControls, usePage } from "@/components/PageProvider.jsx";
import { useMaintenance } from "@/components/MaintenanceProvider.jsx";
@@ -21,6 +20,10 @@ export function Usergroups() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [searchQuery, setSearchQuery] = useState("");
const [newGroupName, setNewGroupName] = useState("");
const [newGroupDescription, setNewGroupDescription] = useState("");
const [createGroupError, setCreateGroupError] = useState("");
const [creatingGroup, setCreatingGroup] = useState(false);
const { showToast } = useToast();
const { maintenance } = useMaintenance();
@@ -171,6 +174,65 @@ export function Usergroups() {
}).format(parsed);
}, []);
const resetNewGroupForm = useCallback(() => {
setNewGroupName("");
setNewGroupDescription("");
setCreateGroupError("");
}, []);
const handleCreateGroup = useCallback(async () => {
if (maintenanceActive) {
return;
}
const trimmedName = newGroupName.trim();
if (!trimmedName) {
setCreateGroupError("Gruppenname ist erforderlich.");
return;
}
setCreatingGroup(true);
setCreateGroupError("");
try {
const payload = { name: trimmedName };
const trimmedDescription = newGroupDescription.trim();
if (trimmedDescription) {
payload.description = trimmedDescription;
}
const response = await axios.post("/api/groups", payload);
showToast({
variant: "success",
title: "Gruppe angelegt",
description: `Die Benutzergruppe "${response.data?.item?.name ?? trimmedName}" wurde erstellt.`
});
resetNewGroupForm();
fetchGroups();
} catch (err) {
const errorCode = err.response?.data?.error;
let message = "Benutzergruppe konnte nicht angelegt werden.";
if (errorCode === "GROUP_NAME_REQUIRED") {
message = "Gruppenname ist erforderlich.";
} else if (errorCode === "GROUP_NAME_TAKEN") {
message = "Der Gruppenname wird bereits verwendet.";
}
setCreateGroupError(message);
showToast({
variant: "error",
title: "Erstellen fehlgeschlagen",
description: message
});
} finally {
setCreatingGroup(false);
}
}, [
maintenanceActive,
newGroupName,
newGroupDescription,
showToast,
resetNewGroupForm,
fetchGroups
]);
const createGroupDisabled = maintenanceActive || creatingGroup;
return (
<div className="mt-12">
<Card className="border border-blue-gray-100 shadow-sm">
@@ -190,6 +252,44 @@ export function Usergroups() {
</div>
)}
<div className="mb-8 rounded-lg border border-blue-gray-100 bg-white p-4 shadow-sm">
<Typography variant="h6" color="blue-gray" className="mb-2">
Neue Benutzergruppe anlegen
</Typography>
<Typography variant="small" className="text-sm text-stormGrey-500 mb-4">
Der Gruppenname ist Pflicht, die Beschreibung optional.
</Typography>
{createGroupError && (
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
{createGroupError}
</div>
)}
<div className="grid gap-4 md:grid-cols-2">
<Input
label="Gruppenname"
value={newGroupName}
onChange={(event) => setNewGroupName(event.target.value)}
disabled={createGroupDisabled}
crossOrigin=""
/>
<Input
label="Beschreibung (optional)"
value={newGroupDescription}
onChange={(event) => setNewGroupDescription(event.target.value)}
disabled={createGroupDisabled}
crossOrigin=""
/>
</div>
<div className="mt-4 flex flex-col gap-3 sm:flex-row sm:items-center">
<Button color="green" onClick={handleCreateGroup} disabled={createGroupDisabled}>
{creatingGroup ? "Speichert ..." : "Gruppe anlegen"}
</Button>
<Button variant="text" color="blue-gray" onClick={resetNewGroupForm} disabled={creatingGroup}>
Formular zurücksetzen
</Button>
</div>
</div>
<div className="mb-8">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="md:flex-1">
+269 -1
View File
@@ -15,6 +15,7 @@ import { useNavigate } from "react-router-dom";
import { PaginationControls, usePage } from "@/components/PageProvider.jsx";
import { useMaintenance } from "@/components/MaintenanceProvider.jsx";
import { useToast } from "@/components/ToastProvider.jsx";
import { AVATAR_COLORS } from "@/data/avatarColors.js";
const normalizeUserGroups = (rawGroups) => {
if (!Array.isArray(rawGroups)) {
@@ -47,6 +48,16 @@ export function Users() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [searchQuery, setSearchQuery] = useState("");
const [availableGroups, setAvailableGroups] = useState([]);
const [groupsLoading, setGroupsLoading] = useState(false);
const [groupsError, setGroupsError] = useState("");
const [newUsername, setNewUsername] = useState("");
const [newEmail, setNewEmail] = useState("");
const [newPassword, setNewPassword] = useState("");
const [newGroupId, setNewGroupId] = useState("");
const [newAvatarColor, setNewAvatarColor] = useState("");
const [createError, setCreateError] = useState("");
const [creatingUser, setCreatingUser] = useState(false);
const { showToast } = useToast();
const { maintenance } = useMaintenance();
@@ -100,13 +111,45 @@ export function Users() {
}
}, [showToast]);
const fetchGroups = useCallback(async () => {
setGroupsLoading(true);
setGroupsError("");
try {
const response = await axios.get("/api/groups");
const items = Array.isArray(response.data?.items) ? response.data.items : [];
const normalized = items
.map((item) => ({
id: Number(item.id),
name: item.name || ""
}))
.filter((group) => Number.isFinite(group.id) && group.id > 0 && group.name)
.sort((a, b) => a.name.localeCompare(b.name, "de-DE"));
setAvailableGroups(normalized);
} catch (err) {
const message = err.response?.data?.error || err.message || "Benutzergruppen konnten nicht geladen werden.";
setGroupsError(message);
showToast({
variant: "error",
title: "Benutzergruppen",
description: message
});
} finally {
setGroupsLoading(false);
}
}, [showToast]);
useEffect(() => {
fetchUsers();
}, [fetchUsers]);
useEffect(() => {
fetchGroups();
}, [fetchGroups]);
const handleRefresh = useCallback(() => {
fetchUsers();
}, [fetchUsers]);
fetchGroups();
}, [fetchUsers, fetchGroups]);
const handleSearchChange = useCallback((event) => {
setSearchQuery(event.target.value);
@@ -195,6 +238,140 @@ export function Users() {
}).format(parsed);
}, []);
const resetNewUserForm = useCallback(() => {
setNewUsername("");
setNewEmail("");
setNewPassword("");
setNewGroupId("");
setNewAvatarColor("");
setCreateError("");
}, []);
const handleCreateUser = useCallback(async () => {
if (maintenanceActive) {
return;
}
const trimmedUsername = newUsername.trim();
if (!trimmedUsername) {
setCreateError("Benutzername ist erforderlich.");
return;
}
const sanitizedPassword = typeof newPassword === "string" ? newPassword.trim() : "";
if (sanitizedPassword.length < 8) {
setCreateError("Passwort muss mindestens 8 Zeichen enthalten.");
return;
}
if (!newGroupId) {
setCreateError("Bitte eine globale Rolle auswählen.");
return;
}
setCreatingUser(true);
setCreateError("");
try {
const payload = {
username: trimmedUsername,
password: sanitizedPassword,
groupId: Number(newGroupId)
};
const trimmedEmail = newEmail.trim();
if (trimmedEmail) {
payload.email = trimmedEmail;
}
if (newAvatarColor) {
payload.avatarColor = newAvatarColor;
}
const response = await axios.post("/api/users", payload);
resetNewUserForm();
fetchUsers();
showToast({
variant: "success",
title: "Benutzer angelegt",
description: `Der Benutzer "${response.data?.item?.username ?? trimmedUsername}" wurde erstellt.`
});
} catch (err) {
const errorCode = err.response?.data?.error;
let message = "Benutzer konnte nicht angelegt werden.";
switch (errorCode) {
case "USERNAME_REQUIRED":
message = "Benutzername ist erforderlich.";
break;
case "INVALID_EMAIL":
message = "Bitte eine gültige E-Mail-Adresse angeben.";
break;
case "INVALID_PASSWORD":
case "PASSWORD_TOO_SHORT":
message = "Passwort muss mindestens 8 Zeichen enthalten.";
break;
case "INVALID_GROUP_ID":
case "GROUP_NOT_FOUND":
message = "Bitte eine gültige globale Rolle auswählen.";
break;
case "USERNAME_TAKEN":
message = "Benutzername wird bereits verwendet.";
break;
case "EMAIL_TAKEN":
message = "E-Mail-Adresse wird bereits verwendet.";
break;
default:
break;
}
setCreateError(message);
showToast({
variant: "error",
title: "Erstellen fehlgeschlagen",
description: message
});
} finally {
setCreatingUser(false);
}
}, [
maintenanceActive,
newUsername,
newEmail,
newPassword,
newGroupId,
newAvatarColor,
showToast,
resetNewUserForm,
fetchUsers
]);
const createDisabled = maintenanceActive || creatingUser || groupsLoading || availableGroups.length === 0;
const groupSelectDisabled = maintenanceActive || creatingUser || groupsLoading || availableGroups.length === 0;
const avatarSelectDisabled = maintenanceActive || creatingUser;
const renderSelectedGroup = useCallback(
(element) => {
if (element && element.props && element.props.children) {
return element.props.children;
}
if (!newGroupId) {
return "Bitte auswählen";
}
const selected = availableGroups.find((group) => String(group.id) === newGroupId);
return selected ? selected.name : "Bitte auswählen";
},
[availableGroups, newGroupId]
);
const renderSelectedAvatar = useCallback(
(element) => {
if (element && element.props && element.props.children) {
return element.props.children;
}
if (!newAvatarColor) {
return "Automatisch (zufällig)";
}
return (
<span className="flex items-center gap-2">
<span className={`h-4 w-4 rounded border border-blue-gray-100 ${newAvatarColor}`} />
<span className="text-xs">{newAvatarColor}</span>
</span>
);
},
[newAvatarColor]
);
return (
<div className="mt-12">
<Card>
@@ -213,6 +390,97 @@ export function Users() {
</div>
)}
<div className="mb-8 rounded-lg border border-blue-gray-100 bg-white p-4 shadow-sm">
<Typography variant="h6" color="blue-gray" className="mb-2">
Neuen Benutzer anlegen
</Typography>
<Typography variant="small" className="text-sm text-stormGrey-500 mb-4">
Benutzername, Passwort (mindestens 8 Zeichen) und eine globale Rolle sind Pflichtfelder.
</Typography>
{createError && (
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
{createError}
</div>
)}
{groupsError && (
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
{groupsError}
</div>
)}
<div className="grid gap-4 md:grid-cols-2">
<Input
label="Benutzername"
value={newUsername}
onChange={(event) => setNewUsername(event.target.value)}
disabled={maintenanceActive || creatingUser}
crossOrigin=""
/>
<Input
label="E-Mail (optional)"
value={newEmail}
onChange={(event) => setNewEmail(event.target.value)}
disabled={maintenanceActive || creatingUser}
crossOrigin=""
/>
<Input
type="password"
label="Passwort"
value={newPassword}
onChange={(event) => setNewPassword(event.target.value)}
disabled={maintenanceActive || creatingUser}
crossOrigin=""
/>
<Select
label="Globale Rolle"
variant="outlined"
value={newGroupId}
onChange={setNewGroupId}
disabled={groupSelectDisabled}
selected={renderSelectedGroup}
>
<Option value="">Bitte auswählen</Option>
{availableGroups.map((group) => (
<Option key={group.id} value={String(group.id)}>
{group.name}
</Option>
))}
</Select>
<Select
label="Avatar-Farbe"
variant="outlined"
value={newAvatarColor}
onChange={setNewAvatarColor}
disabled={avatarSelectDisabled}
selected={renderSelectedAvatar}
>
<Option value="">Automatisch (zufällig)</Option>
{AVATAR_COLORS.map((color) => (
<Option key={color} value={color}>
<span className="flex items-center gap-2">
<span className={`h-4 w-4 rounded border border-blue-gray-100 ${color}`} />
<span className="text-xs">{color}</span>
</span>
</Option>
))}
</Select>
</div>
<div className="mt-4 text-sm text-stormGrey-500">
{groupsLoading
? "Benutzergruppen werden geladen ..."
: availableGroups.length === 0
? "Noch keine Benutzergruppen vorhanden. Bitte legen Sie zuerst eine globale Rolle an."
: ""}
</div>
<div className="mt-4 flex flex-col gap-3 sm:flex-row sm:items-center">
<Button color="green" onClick={handleCreateUser} disabled={createDisabled}>
{creatingUser ? "Speichert ..." : "Benutzer anlegen"}
</Button>
<Button variant="text" color="blue-gray" onClick={resetNewUserForm} disabled={creatingUser}>
Formular zurücksetzen
</Button>
</div>
</div>
<div className="mb-8">
<div className="flex flex-wrap gap-2"> {/* <div className="flex flex-col items-stretch gap-3 md:flex-row md:items-center">
+8
View File
@@ -0,0 +1,8 @@
const React = require('react');
const { renderToString } = require('react-dom/server');
const { Select, Option } = require('@material-tailwind/react');
const element = React.createElement(Select, { value: 'a', onChange: ()=>{} },
React.createElement(Option, { value: 'a' }, 'Alpha'),
React.createElement(Option, { value: 'b' }, 'Beta')
);
console.log(renderToString(element));