diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index bd293f3..6a5584b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -9,9 +9,9 @@ import {SophonFooter} from "./components/elements/SophonFooter" import {ErrorCatcherBox} from "./components/errors/ErrorCatcherBox" import {ResourceDescriptionBox} from "./components/generic/ResourceDescriptionBox" import {GroupCreateBox} from "./components/group/GroupCreateBox" -import {GroupListBox} from "./components/group/GroupListBox" import {GroupMembersBox} from "./components/group/GroupMembersBox" import {GroupRouter} from "./components/group/GroupRouter" +import {GroupStepPage} from "./components/group/GroupStepPage" import {SophonDescriptionBox} from "./components/informative/SophonDescriptionBox" import {InstanceDescriptionBox} from "./components/instance/InstanceDescriptionBox" import {InstanceFormBox} from "./components/instance/InstanceFormBox" @@ -19,12 +19,14 @@ import {InstanceRouter} from "./components/instance/InstanceRouter" import {NotebookListBox} from "./components/notebook/NotebookListBox" import {NotebookRouter} from "./components/notebook/NotebookRouter" import {DebugBox} from "./components/placeholder/DebugBox" +import {ProjectCreateBox} from "./components/project/ProjectCreateBox" import {ProjectListBox} from "./components/project/ProjectListBox" import {ProjectRouter} from "./components/project/ProjectRouter" import {ThemedBluelib} from "./components/theme/ThemedBluelib" import {ThemedTitle} from "./components/theme/ThemedTitle" import {AuthorizationProvider} from "./contexts/authorization" import {CacheProvider} from "./contexts/cache" +import {GroupProvider} from "./contexts/group" import {InstanceProvider} from "./contexts/instance" import {ThemeProvider} from "./contexts/theme" @@ -32,50 +34,53 @@ import {ThemeProvider} from "./contexts/theme" function App({..._}: RouteComponentProps) { return React.useMemo( () => <> - + + + <> } selectedRoute={() => <> - + + + <> - - } + unselectedRoute={AuthorizationStepPage} selectedRoute={() => <> <> - - - } + unselectedRoute={GroupStepPage} selectedRoute={({selection}) => <> - - - - - <> - - - } - selectedRoute={({selection}) => <> - - <> - - } - selectedRoute={(props) => <> - - } - /> - } - /> + + + + + + <> + + + + } + selectedRoute={({selection}) => <> + + + <> + + } + selectedRoute={(props) => <> + + } + /> + } + /> + } /> } diff --git a/frontend/src/components/group/GroupCreateBox.tsx b/frontend/src/components/group/GroupCreateBox.tsx index 9698734..52cbfed 100644 --- a/frontend/src/components/group/GroupCreateBox.tsx +++ b/frontend/src/components/group/GroupCreateBox.tsx @@ -43,7 +43,7 @@ export function GroupCreateBox({viewSet, resource}: GroupCreateBoxProps): JSX.El const description = useFormState( resource?.value.description ?? "", - Validators.notZeroLength, + Validators.alwaysValid, ) const members = diff --git a/frontend/src/components/group/GroupMembersBox.tsx b/frontend/src/components/group/GroupMembersBox.tsx index c264a11..e147f5b 100644 --- a/frontend/src/components/group/GroupMembersBox.tsx +++ b/frontend/src/components/group/GroupMembersBox.tsx @@ -4,18 +4,13 @@ import {Box, Heading, Idiomatic, ListUnordered, UAnnotation} from "@steffo/bluel import * as React from "react" import {useAuthorizationContext} from "../../contexts/authorization" import {useCacheContext} from "../../contexts/cache" -import {ManagedResource} from "../../hooks/useManagedViewSet" -import {SophonResearchGroup} from "../../types/SophonTypes" +import {useGroupContext} from "../../contexts/group" -export interface GroupMembersBoxProps { - resource: ManagedResource -} - - -export function GroupMembersBox({resource}: GroupMembersBoxProps): JSX.Element | null { +export function GroupMembersBox(): JSX.Element | null { const authorization = useAuthorizationContext() const cache = useCacheContext() + const group = useGroupContext() if(!cache) { return null @@ -23,8 +18,11 @@ export function GroupMembersBox({resource}: GroupMembersBoxProps): JSX.Element | if(!cache.users) { return null } + if(!group) { + return null + } - const trueMembers = [...new Set([resource.value.owner, ...resource.value.members])] + const trueMembers = [...new Set([group.value.owner, ...group.value.members])] const users = trueMembers.map((id, index) => { const user = cache.getUserById(id) @@ -45,7 +43,7 @@ export function GroupMembersBox({resource}: GroupMembersBoxProps): JSX.Element | return ( - Members of {resource.value.name} + Members of {group.value.name} {users} diff --git a/frontend/src/components/group/GroupRouter.tsx b/frontend/src/components/group/GroupRouter.tsx index 700c73d..05b1907 100644 --- a/frontend/src/components/group/GroupRouter.tsx +++ b/frontend/src/components/group/GroupRouter.tsx @@ -1,13 +1,12 @@ import * as React from "react" import {useManagedViewSet} from "../../hooks/useManagedViewSet" -import {Dict} from "../../types/ExtraTypes" import {SophonResearchGroup} from "../../types/SophonTypes" import {ViewSetRouter} from "../routing/ViewSetRouter" export interface GroupRouterProps { - unselectedRoute: (props: Dict) => JSX.Element | null, - selectedRoute: (props: Dict) => JSX.Element | null, + unselectedRoute: (props: any) => JSX.Element | null, + selectedRoute: (props: any) => JSX.Element | null, } diff --git a/frontend/src/components/group/GroupStepPage.tsx b/frontend/src/components/group/GroupStepPage.tsx new file mode 100644 index 0000000..51ee885 --- /dev/null +++ b/frontend/src/components/group/GroupStepPage.tsx @@ -0,0 +1,20 @@ +import * as React from "react" +import {ManagedViewSet} from "../../hooks/useManagedViewSet" +import {SophonResearchGroup} from "../../types/SophonTypes" +import {GroupCreateBox} from "./GroupCreateBox" +import {GroupListBox} from "./GroupListBox" + + +export interface GroupStepPageProps { + viewSet: ManagedViewSet +} + + +export function GroupStepPage({viewSet}: GroupStepPageProps): JSX.Element { + return ( + <> + + + + ) +} diff --git a/frontend/src/components/project/ProjectCreateBox.tsx b/frontend/src/components/project/ProjectCreateBox.tsx new file mode 100644 index 0000000..004743f --- /dev/null +++ b/frontend/src/components/project/ProjectCreateBox.tsx @@ -0,0 +1,165 @@ +import {Box, Details, Form, Idiomatic as I, useFormState} from "@steffo/bluelib-react" +import * as React from "react" +import {useAuthorizationContext} from "../../contexts/authorization" +import {useGroupContext} from "../../contexts/group" +import {ManagedResource, ManagedViewSet} from "../../hooks/useManagedViewSet" +import {SophonResearchProject} from "../../types/SophonTypes" +import {Validators} from "../../utils/Validators" + + +export interface ProjectCreateBoxProps { + viewSet?: ManagedViewSet, + resource?: ManagedResource, +} + + +export function ProjectCreateBox({viewSet, resource}: ProjectCreateBoxProps): JSX.Element | null { + const authorization = useAuthorizationContext() + const group = useGroupContext() + + const name = + useFormState( + resource?.value.name ?? "", + Validators.notZeroLength, + ) + + const description = + useFormState( + resource?.value.description ?? "", + Validators.alwaysValid, + ) + + const visibility = + useFormState<"PUBLIC" | "INTERNAL" | "PRIVATE" | undefined>( + resource?.value.visibility ?? undefined, + Validators.notEmpty, + ) + + const slug = + React.useMemo( + () => resource ? resource.value.slug : name.value.replaceAll(/[^A-Za-z0-9-]/g, "-").toLowerCase(), + [resource, name], + ) + + const canAdministrate = + React.useMemo( + () => { + if(resource) { + if(!authorization) { + return false + } + if(!group) { + return false + } + if(!authorization.state.user) { + return false + } + if(!( + group.value.members.includes(authorization.state.user.id) || group.value.owner === authorization.state.user.id + )) { + return false + } + return true + } + else { + return true + } + }, + [authorization, resource], + ) + + const applyChanges = + React.useCallback( + async () => { + if(resource) { + await resource.update({ + name: name.value, + slug: slug, + description: description.value, + visibility: visibility.value, + group: group!.value.slug, + }) + } + else { + await viewSet!.create({ + name: name.value, + slug: slug, + description: description.value, + visibility: visibility.value, + group: group!.value.slug, + }) + } + }, + [viewSet, resource, name, slug, description, visibility, group], + ) + + const canApply = + React.useMemo( + () => name.validity === true && visibility.validity === true && Boolean(authorization?.state.user?.username) && group, + [name, visibility, authorization], + ) + + const hasError = + React.useMemo( + () => viewSet?.operationError || resource?.error, + [viewSet, resource], + ) + + if(!authorization?.state.token || + !( + viewSet || resource + ) || + !canAdministrate) { + return null + } + + return ( + +
+ + {resource ? <>Edit {resource.value.name} : "Create a new research project"} + + +
+ + 0 ? true : undefined} + /> + + + + + {resource ? "Edit" : "Create"} + + + +
+
+
+ ) +} diff --git a/frontend/src/components/project/ProjectDeleteButton.tsx b/frontend/src/components/project/ProjectDeleteButton.tsx new file mode 100644 index 0000000..9ac309b --- /dev/null +++ b/frontend/src/components/project/ProjectDeleteButton.tsx @@ -0,0 +1,40 @@ +import {faTrash} from "@fortawesome/free-solid-svg-icons" +import {FontAwesomeIcon} from "@fortawesome/react-fontawesome" +import * as React from "react" +import {useAuthorizationContext} from "../../contexts/authorization" +import {useGroupContext} from "../../contexts/group" +import {ManagedResource} from "../../hooks/useManagedViewSet" +import {SophonResearchProject} from "../../types/SophonTypes" +import {SafetyButton} from "../elements/SafetyButton" + + +export interface ProjectDeleteButtonProps { + resource: ManagedResource, +} + + +export function ProjectDeleteButton({resource}: ProjectDeleteButtonProps): JSX.Element | null { + const authorization = useAuthorizationContext() + const group = useGroupContext() + + if(!authorization) { + return null + } + if(!group) { + return null + } + if(!authorization.state.user) { + return null + } + if(!( + group.value.members.includes(authorization.state.user.id) || group.value.owner === authorization.state.user.id + )) { + return null + } + + return ( + resource.destroy()}> +  Delete + + ) +} diff --git a/frontend/src/components/project/ProjectResourcePanel.tsx b/frontend/src/components/project/ProjectResourcePanel.tsx index b77a6ff..7047da4 100644 --- a/frontend/src/components/project/ProjectResourcePanel.tsx +++ b/frontend/src/components/project/ProjectResourcePanel.tsx @@ -6,6 +6,7 @@ import {ManagedResource} from "../../hooks/useManagedViewSet" import {SophonResearchProject} from "../../types/SophonTypes" import {Link} from "../elements/Link" import {ResourcePanel} from "../elements/ResourcePanel" +import {ProjectDeleteButton} from "./ProjectDeleteButton" export interface ProjectResourcePanelProps { @@ -34,7 +35,7 @@ export function ProjectResourcePanel({resource}: ProjectResourcePanelProps): JSX - + ) diff --git a/frontend/src/components/project/ProjectRouter.tsx b/frontend/src/components/project/ProjectRouter.tsx index b5f45ba..72e28cd 100644 --- a/frontend/src/components/project/ProjectRouter.tsx +++ b/frontend/src/components/project/ProjectRouter.tsx @@ -16,7 +16,7 @@ export function ProjectRouter({groupPk, ...props}: ProjectRouterProps): JSX.Elem return ( (`/api/projects/by-group/${groupPk}`, "slug")} + viewSet={useManagedViewSet(`/api/projects/by-group/${groupPk}/`, "slug")} pathSegment={"researchProject"} pkKey={"slug"} /> diff --git a/frontend/src/contexts/group.tsx b/frontend/src/contexts/group.tsx new file mode 100644 index 0000000..85b25a6 --- /dev/null +++ b/frontend/src/contexts/group.tsx @@ -0,0 +1,21 @@ +import * as React from "react" +import {ManagedResource} from "../hooks/useManagedViewSet" +import {WithChildren, WithResource} from "../types/ExtraTypes" +import {SophonResearchGroup} from "../types/SophonTypes" + + +const groupContext = React.createContext | undefined>(undefined) +const GroupContext = groupContext + + +/** + * Hook to access the {@link groupContext}. + */ +export function useGroupContext(): ManagedResource | undefined { + return React.useContext(groupContext) +} + + +export function GroupProvider({resource, children}: WithResource & WithChildren): JSX.Element { + return +} diff --git a/frontend/src/types/ExtraTypes.ts b/frontend/src/types/ExtraTypes.ts index 7e8ebfe..e2810b7 100644 --- a/frontend/src/types/ExtraTypes.ts +++ b/frontend/src/types/ExtraTypes.ts @@ -1,4 +1,5 @@ import * as React from "react" +import {ManagedResource, ManagedViewSet} from "../hooks/useManagedViewSet" /** @@ -15,3 +16,19 @@ export interface Dict { export interface WithChildren { children?: React.ReactNode, } + + +/** + * Props including the selection key. + */ +export interface WithResource { + resource: ManagedResource, +} + + +/** + * Props including the viewset key. + */ +export interface WithViewSet { + viewSet: ManagedViewSet, +} \ No newline at end of file