diff --git a/codegen.json b/codegen.json index 83f9993f249..2bfa7b6011f 100644 --- a/codegen.json +++ b/codegen.json @@ -49,9 +49,9 @@ "ActionMeeting": "../../database/types/MeetingAction#default", "ActionMeetingMember": "../../database/types/ActionMeetingMember#default as ActionMeetingMemberDB", "AddApprovedOrganizationDomainsSuccess": "./types/AddApprovedOrganizationDomainsSuccess#AddApprovedOrganizationDomainsSuccessSource", + "AddPokerTemplateSuccess": "./types/AddPokerTemplateSuccess#AddPokerTemplateSuccessSource", "AddReactjiToReactableSuccess": "./types/AddReactjiToReactableSuccess#AddReactjiToReactableSuccessSource", "AddReflectTemplateSuccess": "./types/AddReflectTemplateSuccess#AddReflectTemplateSuccessSource", - "AddPokerTemplateSuccess": "./types/AddPokerTemplateSuccess#AddPokerTemplateSuccessSource", "AddTranscriptionBotSuccess": "./types/AddTranscriptionBotSuccess#AddTranscriptionBotSuccessSource", "AddedNotification": "./types/AddedNotification#AddedNotificationSource", "AgendaItem": "../../database/types/AgendaItem#default as AgendaItemDB", @@ -132,6 +132,7 @@ "TeamPromptResponse": "../../postgres/queries/getTeamPromptResponsesByIds#TeamPromptResponse", "TemplateDimension": "../../database/types/TemplateDimension#default", "TimelineEventTeamPromptComplete": "./types/TimelineEventTeamPromptComplete#TimelineEventTeamPromptCompleteSource", + "ToggleFavoriteTemplateSuccess": "./types/ToggleFavoriteTemplateSuccess#ToggleFavoriteTemplateSuccessSource", "ToggleSummaryEmailSuccess": "./types/ToggleSummaryEmailSuccess#ToggleSummaryEmailSuccessSource", "TopRetroTemplate": "./types/TopRetroTemplate#TopRetroTemplateSource", "UpdateAutoJoinSuccess": "./types/UpdateAutoJoinSuccess#UpdateAutoJoinSuccessSource", @@ -140,9 +141,9 @@ "UpdateFeatureFlagPayload": "./types/UpdateFeatureFlagPayload#UpdateFeatureFlagPayloadSource", "UpdateGitLabDimensionFieldSuccess": "./types/UpdateGitLabDimensionFieldSuccess#UpdateGitLabDimensionFieldSuccessSource", "UpdateMeetingPromptSuccess": "./types/UpdateMeetingPromptSuccess#UpdateMeetingPromptSuccessSource", + "UpdateMeetingTemplateSuccess": "./types/UpdateMeetingTemplateSuccess#UpdateMeetingTemplateSuccessSource", "UpdateOrgPayload": "./types/UpdateOrgPayload#UpdateOrgPayloadSource", "UpdateRecurrenceSettingsSuccess": "./types/UpdateRecurrenceSettingsSuccess#UpdateRecurrenceSettingsSuccessSource", - "UpdateMeetingTemplateSuccess": "./types/UpdateMeetingTemplateSuccess#UpdateMeetingTemplateSuccessSource", "UpdateTaskPayload": "./types/UpdateTaskPayload#UpdateTaskPayloadSource", "UpdateTemplateCategorySuccess": "./types/UpdateTemplateCategorySuccess#UpdateTemplateCategorySuccessSource", "UpdateUserProfilePayload": "./types/UpdateUserProfilePayload#UpdateUserProfilePayloadSource", diff --git a/packages/client/components/ActivityLibrary/ActivityCardFavorite.tsx b/packages/client/components/ActivityLibrary/ActivityCardFavorite.tsx index 1a703d338f3..51b5cfbf60f 100644 --- a/packages/client/components/ActivityLibrary/ActivityCardFavorite.tsx +++ b/packages/client/components/ActivityLibrary/ActivityCardFavorite.tsx @@ -1,22 +1,44 @@ import {Favorite} from '@mui/icons-material' +import graphql from 'babel-plugin-relay/macro' import clsx from 'clsx' -import React, {useState} from 'react' +import React from 'react' +import {useFragment} from 'react-relay' +import {ActivityCardFavorite_user$key} from '../../__generated__/ActivityCardFavorite_user.graphql' +import useAtmosphere from '../../hooks/useAtmosphere' +import useMutationProps from '../../hooks/useMutationProps' +import ToggleFavoriteTemplateMutation from '../../mutations/ToggleFavoriteTemplateMutation' import {Tooltip} from '../../ui/Tooltip/Tooltip' import {TooltipContent} from '../../ui/Tooltip/TooltipContent' import {TooltipTrigger} from '../../ui/Tooltip/TooltipTrigger' type Props = { className?: string + templateId: string + viewerRef: ActivityCardFavorite_user$key } const ActivityCardFavorite = (props: Props) => { - const {className} = props - const [isSelected, setIsSelected] = useState(false) - const tooltipCopy = isSelected ? 'Remove from favorites' : 'Add to favorites' + const {className, templateId, viewerRef} = props + const atmosphere = useAtmosphere() + const {onError, onCompleted} = useMutationProps() + + const viewer = useFragment( + graphql` + fragment ActivityCardFavorite_user on User { + favoriteTemplates { + id + } + } + `, + viewerRef + ) + const favoriteTemplateIds = viewer.favoriteTemplates.map((template) => template.id) + const isFavorite = favoriteTemplateIds.includes(templateId) + const tooltipCopy = isFavorite ? 'Remove from favorites' : 'Add to favorites' const handleClick = (e: React.MouseEvent) => { e.preventDefault() - setIsSelected((prev) => !prev) + ToggleFavoriteTemplateMutation(atmosphere, {templateId}, {onError, onCompleted}) } return ( @@ -32,7 +54,7 @@ const ActivityCardFavorite = (props: Props) => { onClick={handleClick} className='flex h-full w-full cursor-pointer items-center justify-center bg-transparent' > - + diff --git a/packages/client/components/ActivityLibrary/ActivityDetails/TemplateDetails.tsx b/packages/client/components/ActivityLibrary/ActivityDetails/TemplateDetails.tsx index 54ec5449623..0adf77aab6b 100644 --- a/packages/client/components/ActivityLibrary/ActivityDetails/TemplateDetails.tsx +++ b/packages/client/components/ActivityLibrary/ActivityDetails/TemplateDetails.tsx @@ -133,6 +133,7 @@ export const TemplateDetails = (props: Props) => { const viewer = useFragment( graphql` fragment TemplateDetails_user on User { + ...ActivityCardFavorite_user preferredTeamId teams { ...TeamPickerModal_teams @@ -260,7 +261,11 @@ export const TemplateDetails = (props: Props) => {
{description}
- +
{ +const ActivityGrid = (props: ActivityGridProps) => { + const {templates, selectedCategory, viewerRef} = props + const viewer = useFragment( + graphql` + fragment ActivityGrid_user on User { + ...ActivityCardFavorite_user + } + `, + viewerRef ?? null + ) return ( <> {templates.map((template) => { @@ -43,7 +56,13 @@ const ActivityGrid = ({templates, selectedCategory}: ActivityGridProps) => { src={template.illustrationUrl} category={template.category as CategoryID} /> - + {viewer && ( + + )} { // If there's a search query, just use the search filter results return filteredTemplates } + if (categoryId === 'favorite') { + return viewer.favoriteTemplates + } return filteredTemplates.filter((template) => categoryId === QUICK_START_CATEGORY_ID @@ -344,7 +351,7 @@ export const ActivityLibrary = (props: Props) => { style={{ color: category === 'favorite' - ? category === categoryId + ? category === categoryId && searchQuery.length === 0 ? 'white' : 'red' : undefined @@ -388,6 +395,7 @@ export const ActivityLibrary = (props: Props) => {
@@ -400,6 +408,7 @@ export const ActivityLibrary = (props: Props) => {
diff --git a/packages/client/components/ActivityLibrary/ActivityLibraryEmptyState.tsx b/packages/client/components/ActivityLibrary/ActivityLibraryEmptyState.tsx index f68ac23d1fe..26b1e28183b 100644 --- a/packages/client/components/ActivityLibrary/ActivityLibraryEmptyState.tsx +++ b/packages/client/components/ActivityLibrary/ActivityLibraryEmptyState.tsx @@ -16,9 +16,13 @@ const ActivityLibraryEmptyState = (props: Props) => { if (categoryId === 'favorite') { return ( -
-
- Favorite placeholder +
+
+ Favorite placeholder
= style={{ color: 'inherit', display: 'flex', - alignItems: 'center', - fontSize: '18px' + fontSize: '22px' }} /> ), diff --git a/packages/client/mutations/ToggleFavoriteTemplateMutation.ts b/packages/client/mutations/ToggleFavoriteTemplateMutation.ts new file mode 100644 index 00000000000..c052d601311 --- /dev/null +++ b/packages/client/mutations/ToggleFavoriteTemplateMutation.ts @@ -0,0 +1,41 @@ +import graphql from 'babel-plugin-relay/macro' +import {commitMutation} from 'react-relay' +import {ToggleFavoriteTemplateMutation as TToggleFavoriteTemplateMutation} from '../__generated__/ToggleFavoriteTemplateMutation.graphql' +import {StandardMutation} from '../types/relayMutations' + +graphql` + fragment ToggleFavoriteTemplateMutation_viewer on ToggleFavoriteTemplateSuccess { + user { + id + ...ActivityCardFavorite_user + } + } +` + +const mutation = graphql` + mutation ToggleFavoriteTemplateMutation($templateId: ID!) { + toggleFavoriteTemplate(templateId: $templateId) { + ... on ErrorPayload { + error { + message + } + } + ...ToggleFavoriteTemplateMutation_viewer @relay(mask: false) + } + } +` + +const ToggleFavoriteTemplateMutation: StandardMutation = ( + atmosphere, + variables, + {onError, onCompleted} +) => { + return commitMutation(atmosphere, { + mutation, + variables, + onCompleted, + onError + }) +} + +export default ToggleFavoriteTemplateMutation diff --git a/packages/server/database/types/User.ts b/packages/server/database/types/User.ts index 0f5002becae..05431d88791 100644 --- a/packages/server/database/types/User.ts +++ b/packages/server/database/types/User.ts @@ -9,6 +9,7 @@ interface Input { id?: string preferredName: string email: string + favoriteTemplateIds?: string[] featureFlags?: string[] lastSeenAt?: Date lastSeenAtURLs?: string[] @@ -28,6 +29,7 @@ export default class User { id: string preferredName: string email: string + favoriteTemplateIds: string[] featureFlags: string[] lastSeenAt: Date lastSeenAtURLs: string[] | null @@ -55,6 +57,7 @@ export default class User { createdAt, picture, updatedAt, + favoriteTemplateIds, featureFlags, lastSeenAt, lastSeenAtURLs, @@ -73,6 +76,7 @@ export default class User { this.createdAt = createdAt || now this.picture = picture this.updatedAt = updatedAt || now + this.favoriteTemplateIds = favoriteTemplateIds || [] this.featureFlags = featureFlags || [] this.identities = identities || [] this.inactive = inactive || false diff --git a/packages/server/dataloader/customLoaderMakers.ts b/packages/server/dataloader/customLoaderMakers.ts index fb3931b1506..0ceba9e5255 100644 --- a/packages/server/dataloader/customLoaderMakers.ts +++ b/packages/server/dataloader/customLoaderMakers.ts @@ -842,6 +842,27 @@ export const isCompanyDomain = (parent: RootDataLoader) => { ) } +export const favoriteTemplateIds = (parent: RootDataLoader) => { + return new DataLoader( + async (userIds) => { + const pg = getKysely() + const users = await pg + .selectFrom('User') + .select(['id', 'favoriteTemplateIds']) + .where('id', 'in', userIds) + .execute() + + const userIdToFavoriteTemplateIds = new Map( + users.map((user) => [user.id, user.favoriteTemplateIds]) + ) + return userIds.map((userId) => userIdToFavoriteTemplateIds.get(userId) || []) + }, + { + ...parent.dataLoaderOptions + } + ) +} + export const fileStoreAsset = (parent: RootDataLoader) => { return new DataLoader( async (urls) => { diff --git a/packages/server/graphql/public/mutations/toggleFavoriteTemplate.ts b/packages/server/graphql/public/mutations/toggleFavoriteTemplate.ts new file mode 100644 index 00000000000..31874a1e1b5 --- /dev/null +++ b/packages/server/graphql/public/mutations/toggleFavoriteTemplate.ts @@ -0,0 +1,37 @@ +import getKysely from '../../../postgres/getKysely' +import {getUserId} from '../../../utils/authorization' +import {MutationResolvers} from '../resolverTypes' + +const toggleFavoriteTemplate: MutationResolvers['toggleFavoriteTemplate'] = async ( + _source, + {templateId}, + {authToken, dataLoader} +) => { + const viewerId = getUserId(authToken) + const pg = getKysely() + const userId = getUserId(authToken) + + const favoriteTemplateIds = await dataLoader.get('favoriteTemplateIds').load(viewerId) + + let updatedFavoriteTemplateIds + + const isCurrentlyFavorite = favoriteTemplateIds.includes(templateId) + + if (isCurrentlyFavorite) { + updatedFavoriteTemplateIds = favoriteTemplateIds.filter((id) => id !== templateId) + } else { + updatedFavoriteTemplateIds = [...favoriteTemplateIds, templateId] + } + + await pg + .updateTable('User') + .set({ + favoriteTemplateIds: updatedFavoriteTemplateIds + }) + .where('id', '=', userId) + .execute() + + return true +} + +export default toggleFavoriteTemplate diff --git a/packages/server/graphql/public/typeDefs/User.graphql b/packages/server/graphql/public/typeDefs/User.graphql index bd29eaf1c02..ab75ef73a4c 100644 --- a/packages/server/graphql/public/typeDefs/User.graphql +++ b/packages/server/graphql/public/typeDefs/User.graphql @@ -57,6 +57,11 @@ type User { """ email: Email! + """ + The user's favorite meeting templates + """ + favoriteTemplates: [MeetingTemplate!]! + """ Any super power given to the user via a super user """ diff --git a/packages/server/graphql/public/typeDefs/toggleFavoriteTemplate.graphql b/packages/server/graphql/public/typeDefs/toggleFavoriteTemplate.graphql new file mode 100644 index 00000000000..1e046e2becd --- /dev/null +++ b/packages/server/graphql/public/typeDefs/toggleFavoriteTemplate.graphql @@ -0,0 +1,20 @@ +extend type Mutation { + """ + Add or remove the template to the user's favorite templates + """ + toggleFavoriteTemplate( + """ + The ID of the template to be toggled as a favorite + """ + templateId: ID! + ): ToggleFavoriteTemplatePayload! +} + +union ToggleFavoriteTemplatePayload = ErrorPayload | ToggleFavoriteTemplateSuccess + +type ToggleFavoriteTemplateSuccess { + """ + The user who's favorite templates were updated + """ + user: User! +} diff --git a/packages/server/graphql/public/types/ToggleFavoriteTemplateSuccess.ts b/packages/server/graphql/public/types/ToggleFavoriteTemplateSuccess.ts new file mode 100644 index 00000000000..e722b9192a5 --- /dev/null +++ b/packages/server/graphql/public/types/ToggleFavoriteTemplateSuccess.ts @@ -0,0 +1,14 @@ +import {getUserId} from '../../../utils/authorization' +import {ToggleFavoriteTemplateSuccessResolvers} from '../resolverTypes' + +export type ToggleFavoriteTemplateSuccessSource = Record + +const ToggleFavoriteTemplateSuccess: ToggleFavoriteTemplateSuccessResolvers = { + user: async (_, _args, {dataLoader, authToken}) => { + const userId = getUserId(authToken) + const user = await dataLoader.get('users').loadNonNull(userId) + return user + } +} + +export default ToggleFavoriteTemplateSuccess diff --git a/packages/server/graphql/public/types/User.ts b/packages/server/graphql/public/types/User.ts index 8f9f0a40040..b5951a80541 100644 --- a/packages/server/graphql/public/types/User.ts +++ b/packages/server/graphql/public/types/User.ts @@ -19,6 +19,7 @@ import {getSSOMetadataFromURL} from '../../../utils/getSSOMetadataFromURL' import sendToSentry from '../../../utils/sendToSentry' import standardError from '../../../utils/standardError' import {getStripeManager} from '../../../utils/stripe' +import isValid from '../../isValid' import connectionFromTemplateArray from '../../queries/helpers/connectionFromTemplateArray' import {getFeatureTier} from '../../types/helpers/getFeatureTier' import getSignOnURL from '../mutations/helpers/SAMLHelpers/getSignOnURL' @@ -85,6 +86,9 @@ const User: UserResolvers = { } return request }, + favoriteTemplates: async ({favoriteTemplateIds}, _args, {dataLoader}) => { + return (await dataLoader.get('meetingTemplates').loadMany(favoriteTemplateIds)).filter(isValid) + }, featureFlags: ({featureFlags}) => { return Object.fromEntries(featureFlags.map((flag) => [flag as any, true])) }, diff --git a/packages/server/postgres/migrations/1714598525167_addFavoriteTemplateIds.ts b/packages/server/postgres/migrations/1714598525167_addFavoriteTemplateIds.ts new file mode 100644 index 00000000000..b2a17b04b43 --- /dev/null +++ b/packages/server/postgres/migrations/1714598525167_addFavoriteTemplateIds.ts @@ -0,0 +1,28 @@ +import {Kysely, PostgresDialect, sql} from 'kysely' +import getPg from '../getPg' + +export async function up() { + const pg = new Kysely({ + dialect: new PostgresDialect({ + pool: getPg() + }) + }) + + await sql` + ALTER TABLE "User" + ADD COLUMN "favoriteTemplateIds" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[]; +`.execute(pg) +} + +export async function down() { + const pg = new Kysely({ + dialect: new PostgresDialect({ + pool: getPg() + }) + }) + + await sql` + ALTER TABLE "User" + DROP COLUMN "favoriteTemplateIds"; +`.execute(pg) +}