Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
202 changes: 140 additions & 62 deletions Cargo.lock

Large diffs are not rendered by default.

31 changes: 31 additions & 0 deletions graphql/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -568,6 +568,29 @@ input CurateWordAudioInput {
includeInEditedCollection: Boolean!
}

input CurrentUserUpdate {
"""
User-facing name for this contributor/curator
"""
displayName: String
"""
URL to the avatar of the user (optional)
"""
avatarUrl: String
"""
Biography of the user (optional)
"""
bio: String
"""
Organization of the user (optional)
"""
organization: String
"""
Location of the user (optional)
"""
location: String
}

type Date {
"""
The year of this date
Expand Down Expand Up @@ -1062,6 +1085,10 @@ type Mutation {
"""
updateUser(user: UserUpdate!): User!
"""
Updates the current logged in user's information
"""
updateCurrentUser(user: CurrentUserUpdate!): User!
"""
Adds a bookmark to the user's list of bookmarks.
"""
addBookmark(documentId: UUID!): AnnotatedDoc!
Expand Down Expand Up @@ -1304,6 +1331,10 @@ type Query {
dailpUserById(id: UUID!): User!
abbreviationIdFromShortName(shortName: String!): UUID!
menuBySlug(slug: String!): Menu!
"""
Gets the current dailp_user, if authenticated
"""
currentUser: User!
}

"""
Expand Down
37 changes: 36 additions & 1 deletion graphql/src/query.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use dailp::{
comment::{CommentParent, CommentUpdate, DeleteCommentInput, PostCommentInput},
page::{NewPageInput, Page},
slugify_ltree,
user::{User, UserUpdate},
user::{CurrentUserUpdate, User, UserId, UserUpdate},
AnnotatedForm, AnnotatedSeg, AttachAudioToDocumentInput, AttachAudioToWordInput,
CollectionChapter, Contributor, ContributorRole, CreateEditedCollectionInput,
CurateDocumentAudioInput, CurateWordAudioInput, Date, DeleteContributorAttribution,
Expand Down Expand Up @@ -376,6 +376,19 @@ impl Query {
.await?)
}

/// Gets the current dailp_user, if authenticated
#[graphql(guard = "AuthGuard")]
async fn current_user(&self, context: &Context<'_>) -> FieldResult<User> {
let user = context
.data_opt::<UserInfo>()
.ok_or_else(|| anyhow::format_err!("User is not signed in"))?;
Ok(context
.data::<DataLoader<Database>>()?
.loader()
.dailp_user_by_id(&user.id)
.await?)
}

async fn abbreviation_id_from_short_name(
&self,
context: &Context<'_>,
Expand Down Expand Up @@ -592,6 +605,28 @@ impl Mutation {
return Ok(user_object);
}

/// Updates the current logged in user's information
#[graphql(guard = "AuthGuard")]
async fn update_current_user(
&self,
context: &Context<'_>,
user: CurrentUserUpdate,
) -> FieldResult<User> {
let current_user = context
.data_opt::<UserInfo>()
.ok_or_else(|| anyhow::format_err!("User is not signed in"))?;
let current_user_id = UserId::from(current_user.id);
let db = context.data::<DataLoader<Database>>()?.loader();

let user_update = UserUpdate::from_current_user_update(current_user_id, user);
db.update_dailp_user(user_update).await?;

let user_object = db.dailp_user_by_id(&current_user.id).await?;

// We return the user object, for GraphCache interop
return Ok(user_object);
}

/// Adds a bookmark to the user's list of bookmarks.
#[graphql(guard = "AuthGuard")]
async fn add_bookmark(
Expand Down
33 changes: 31 additions & 2 deletions types/src/user.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
//! Provides a type representing a user.
use crate::auth::UserGroup;
use crate::Date;
use async_graphql::MaybeUndefined;
use serde::{Deserialize, Serialize};
use uuid::Uuid;

use crate::auth::UserGroup;

/// A user id tied back to AWS Cognito `sub` claim.
#[derive(Clone, Eq, PartialEq, Hash, Serialize, Deserialize, Debug, async_graphql::NewType)]
pub struct UserId(pub String);
Expand Down Expand Up @@ -67,3 +66,33 @@ pub struct UserUpdate {
/// Role of the user (optional)
pub role: MaybeUndefined<UserGroup>,
}

/// Converts a `CurrentUserUpdate` into a `UserUpdate`.
impl UserUpdate {
/// This is useful for updating the user profile with the current user's data because the id is not known in the `CurrentUserUpdate`.
pub fn from_current_user_update(id: UserId, update: CurrentUserUpdate) -> Self {
Self {
id,
display_name: update.display_name,
avatar_url: update.avatar_url,
bio: update.bio,
organization: update.organization,
location: update.location,
role: MaybeUndefined::Null, // Role is not updated from CurrentUserUpdate
}
}
}

#[derive(async_graphql::InputObject)]
pub struct CurrentUserUpdate {
/// User-facing name for this contributor/curator
pub display_name: MaybeUndefined<String>,
/// URL to the avatar of the user (optional)
pub avatar_url: MaybeUndefined<String>,
/// Biography of the user (optional)
pub bio: MaybeUndefined<String>,
/// Organization of the user (optional)
pub organization: MaybeUndefined<String>,
/// Location of the user (optional)
pub location: MaybeUndefined<String>,
}
6 changes: 5 additions & 1 deletion website/src/components/authenticated-users/account-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,11 @@ export const AccountMenu = () => {
}

const AccountActionsMenu = (p: { groups?: readonly Dailp.UserGroup[] }) => {
let actions = [<Link href="/dashboard">Dashboard</Link>, <ConfirmLogout />]
let actions = [
<Link href="/profile">My Profile</Link>,
<Link href="/dashboard">Dashboard</Link>,
<ConfirmLogout />,
]
let groups: string[] =
p.groups && p.groups.length > 0
? p.groups.map((x) => x.toLowerCase().slice(0, x.length - 1))
Expand Down
15 changes: 14 additions & 1 deletion website/src/components/dashboard/dashboard.css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ export const dashboardHeader = style({
left: 0,
display: "flex",
flexFlow: "column nowrap",
alignItems: "center",
alignItems: "left",
top: 55,
width: "100%",
zIndex: layers.base,
Expand Down Expand Up @@ -120,3 +120,16 @@ export const noBullets = style({
export const cardShadow = style({
boxShadow: "2px 2px 4px rgba(0, 0, 0, 0.2)",
})

export const dashboardLayout = style({
display: "flex",
gap: "2rem",
width: "100%",
minHeight: `calc(100vh - 200px)`,
})

export const mainContent = style({
paddingTop: vspace.large,
flex: 4,
minWidth: 0,
})
111 changes: 68 additions & 43 deletions website/src/components/dashboard/dashboard.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
import { Tab, TabList, TabPanel, useDialogState } from "reakit"
import {
unstable_Form as Form,
Tab,
TabList,
TabPanel,
useDialogState,
} from "reakit"
import { UserRole, useUserRole } from "src/auth"
import { useMediaQuery } from "src/custom-hooks"
import {
DocumentFieldsFragment,
useBookmarkedDocumentsQuery,
} from "src/graphql/dailp"
import { useScrollableTabState } from "src/scrollable-tabs"
import { mediaQueries } from "src/style/constants"
import Link from "../link"
import { BookmarkCard } from "./bookmark-card"
import * as css from "./dashboard.css"
import { FormProvider } from "./edit-profile-sidebar-form-context"
import { LayoutVariant, ProfileSidebarLayout } from "./profile-sidebar-layout"

enum Tabs {
ACTIVITY = "activity-tab",
Expand All @@ -18,51 +28,66 @@ enum Tabs {
export const Dashboard = () => {
const tabs = useScrollableTabState({ selectedId: Tabs.BOOKMARKS })
const curRole = useUserRole()
const isDesktop = useMediaQuery(mediaQueries.medium)

return (
<>
<h1 className={css.dashboardHeader}>Dashboard</h1>
<div className={css.wideAndTop}>
<TabList
{...tabs}
id="document-tabs-header"
className={css.dashboardTabs}
aria-label="Document View Types"
>
<Tab {...tabs} id={Tabs.BOOKMARKS} className={css.dashboardTab}>
Bookmarked Documents
</Tab>
<Tab {...tabs} id={Tabs.ACTIVITY} className={css.dashboardTab}>
Recent Activity
</Tab>
{curRole == UserRole.Admin && (
<Tab {...tabs} id={Tabs.ADMIN_TOOLS} className={css.dashboardTab}>
Admin tools
{/* Container for the ProfileSidebar and main dashboard content*/}
<div className={css.dashboardLayout}>
{/* Profile sidebar - 1/4 width */}

{isDesktop && (
<FormProvider>
<ProfileSidebarLayout layout={LayoutVariant.Sidebar} />
</FormProvider>
)}

{/* Main dashboard content - 3/4 width */}
<div className={css.mainContent}>
<h1 className={css.dashboardHeader}>Dashboard</h1>
<TabList
{...tabs}
id="document-tabs-header"
className={css.dashboardTabs}
aria-label="Document View Types"
>
<Tab {...tabs} id={Tabs.BOOKMARKS} className={css.dashboardTab}>
Bookmarked Documents
</Tab>
)}
</TabList>

<TabPanel
{...tabs}
id={Tabs.BOOKMARKS}
className={css.dashboardTabPanel}
>
<BookmarksTab />
</TabPanel>

<TabPanel
{...tabs}
id={Tabs.ACTIVITY}
className={css.dashboardTabPanel}
>
<ActivityTab />
</TabPanel>
<TabPanel
{...tabs}
id={Tabs.ADMIN_TOOLS}
className={css.dashboardTabPanel}
>
<AdminToolsTab />
</TabPanel>
<Tab {...tabs} id={Tabs.ACTIVITY} className={css.dashboardTab}>
Recent Activity
</Tab>
{curRole == UserRole.Admin && (
<Tab {...tabs} id={Tabs.ADMIN_TOOLS} className={css.dashboardTab}>
Admin tools
</Tab>
)}
</TabList>

<TabPanel
{...tabs}
id={Tabs.BOOKMARKS}
className={css.dashboardTabPanel}
>
<BookmarksTab />
</TabPanel>

<TabPanel
{...tabs}
id={Tabs.ACTIVITY}
className={css.dashboardTabPanel}
>
<ActivityTab />
</TabPanel>

<TabPanel
{...tabs}
id={Tabs.ADMIN_TOOLS}
className={css.dashboardTabPanel}
>
<AdminToolsTab />
</TabPanel>
</div>
</div>
</>
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import React, { createContext, useContext, useState } from "react"
import {
unstable_FormStateReturn as FormStateReturn,
unstable_useFormState as useFormState,
} from "reakit"
import * as Dailp from "../../graphql/dailp"

type FormContextType = {
form: FormStateReturn<any | undefined>
isEditing: boolean
setIsEditing: (bool: boolean) => void
}

const FormContext = createContext<FormContextType>({} as FormContextType)

/** Instantiates a form state used to keep track of the current user and information about all its features. */
export const FormProvider = (props: { children: any }) => {
const [isEditing, setIsEditing] = useState(false)
const user: Dailp.User = {} as Dailp.User

const [updateUserResult, updateUser] = Dailp.useUpdateCurrentUserMutation()

/** Calls the backend GraphQL mutation to update a user's profile data. */
const runUpdate = async (variables: { user: Dailp.CurrentUserUpdate }) => {
await updateUser(variables)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might be a good idea to add error handling or a try/catch around this

}

const form = useFormState({
values: {
user,
},
onValidate: (values) => {
if (!values || !values.user) {
throw { values: "No user found" }
}
},
onSubmit: (values) => {
console.log("Submitting user profile update")
console.log(values.user.displayName)
console.log(values.user.avatarUrl)
console.log(values.user.bio)
console.log(values.user.organization)
console.log(values.user.location)
setIsEditing(false)

runUpdate({
user: {
displayName: values.user.displayName,
avatarUrl: values.user.avatarUrl,
bio: values.user.bio,
organization: values.user.organization,
location: values.user.location,
},
})
},
})

return (
<FormContext.Provider value={{ form, isEditing, setIsEditing }}>
{props.children}
</FormContext.Provider>
)
}

export const useForm = () => {
const context = useContext(FormContext)

return context
}
Loading