diff --git a/packages/shared/src/components/squads/SquadPageHeader.tsx b/packages/shared/src/components/squads/SquadPageHeader.tsx index 24ba30afb2..3990d193cc 100644 --- a/packages/shared/src/components/squads/SquadPageHeader.tsx +++ b/packages/shared/src/components/squads/SquadPageHeader.tsx @@ -33,6 +33,7 @@ import { TypographyType, } from '../typography/Typography'; import { ClickableText } from '../buttons/ClickableText'; +import { SquadStack } from './stack/SquadStack'; interface SquadPageHeaderProps { squad: Squad; @@ -178,6 +179,9 @@ export function SquadPageHeader({ )} +
+ +
void; + onDelete?: (item: SourceStack) => void; +} + +export function SourceStackItem({ + item, + canEdit, + onEdit, + onDelete, +}: SourceStackItemProps): ReactElement { + const { tool } = item; + const title = item.title ?? tool.title; + + return ( +
+
+ {tool.faviconUrl ? ( + + ) : ( + + )} + {!!title && ( +
+ + {title} + +
+ )} +
+ {canEdit && ( +
+ {onEdit && ( +
+ )} +
+ ); +} diff --git a/packages/shared/src/components/squads/stack/SourceStackModal.tsx b/packages/shared/src/components/squads/stack/SourceStackModal.tsx new file mode 100644 index 0000000000..9858a086df --- /dev/null +++ b/packages/shared/src/components/squads/stack/SourceStackModal.tsx @@ -0,0 +1,188 @@ +import type { ReactElement } from 'react'; +import React, { useMemo, useState } from 'react'; +import { FormProvider, useForm } from 'react-hook-form'; +import { z } from 'zod'; +import { zodResolver } from '@hookform/resolvers/zod'; +import type { ModalProps } from '../../modals/common/Modal'; +import { Modal } from '../../modals/common/Modal'; +import { TextField } from '../../fields/TextField'; +import { Button, ButtonVariant } from '../../buttons/Button'; +import { ModalHeader } from '../../modals/common/ModalHeader'; +import { useViewSize, ViewSize } from '../../../hooks'; +import type { + SourceStack, + AddSourceStackInput, +} from '../../../graphql/source/sourceStack'; +import type { DatasetTool } from '../../../graphql/user/userStack'; +import { useStackSearch } from '../../../features/profile/hooks/useStackSearch'; +import { PlusIcon } from '../../icons'; + +const sourceStackFormSchema = z.object({ + title: z.string().min(1, 'Title is required').max(255), +}); + +type SourceStackFormData = z.infer; + +type SourceStackModalProps = Omit & { + onSubmit: (input: AddSourceStackInput) => Promise; + existingItem?: SourceStack; +}; + +export function SourceStackModal({ + onSubmit, + existingItem, + ...rest +}: SourceStackModalProps): ReactElement { + const [showSuggestions, setShowSuggestions] = useState(false); + const isMobile = useViewSize(ViewSize.MobileL); + const isEditing = !!existingItem; + + const methods = useForm({ + resolver: zodResolver(sourceStackFormSchema), + defaultValues: { + title: existingItem?.title ?? existingItem?.tool.title ?? '', + }, + }); + + const { + register, + handleSubmit, + watch, + setValue, + formState: { errors, isSubmitting }, + } = methods; + + const title = watch('title'); + + const { results: suggestions } = useStackSearch(title); + + const canSubmit = title.trim().length > 0; + + const handleSelectSuggestion = (suggestion: DatasetTool) => { + setValue('title', suggestion.title); + setShowSuggestions(false); + }; + + const onFormSubmit = handleSubmit(async (data) => { + await onSubmit({ + title: data.title.trim(), + }); + rest.onRequestClose?.(null); + }); + + const filteredSuggestions = useMemo(() => { + if (!showSuggestions || title.length < 1) { + return []; + } + return suggestions; + }, [showSuggestions, suggestions, title.length]); + + return ( + + + + {isEditing ? 'Edit Stack Item' : 'Add to Squad Stack'} + + + ), + rightButtonProps: { + variant: ButtonVariant.Primary, + disabled: !canSubmit || isSubmitting, + loading: isSubmitting, + }, + copy: { right: isEditing ? 'Save' : 'Add' }, + }} + kind={Modal.Kind.FlexibleCenter} + size={Modal.Size.Small} + {...rest} + > +
+ + + {isEditing ? 'Edit Stack Item' : 'Add to Squad Stack'} + + + + {/* Title with autocomplete */} +
+ { + setValue('title', e.target.value); + if (!isEditing) { + setShowSuggestions(true); + } + }} + onFocus={() => { + if (!isEditing) { + setShowSuggestions(true); + } + }} + /> + {!isEditing && showSuggestions && title.trim() && ( +
+ {filteredSuggestions.map((suggestion) => ( + + ))} + {!filteredSuggestions.some( + (s) => s.title.toLowerCase() === title.trim().toLowerCase(), + ) && ( + + )} +
+ )} +
+ + {!isMobile && ( + + )} +
+
+
+
+ ); +} diff --git a/packages/shared/src/components/squads/stack/SourceStackSection.tsx b/packages/shared/src/components/squads/stack/SourceStackSection.tsx new file mode 100644 index 0000000000..441c961305 --- /dev/null +++ b/packages/shared/src/components/squads/stack/SourceStackSection.tsx @@ -0,0 +1,56 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import type { SourceStack } from '../../../graphql/source/sourceStack'; +import { + Typography, + TypographyType, + TypographyColor, +} from '../../typography/Typography'; +import { Pill, PillSize } from '../../Pill'; +import { SourceStackItem } from './SourceStackItem'; + +interface SourceStackSectionProps { + section: string; + items: SourceStack[]; + canEdit: boolean; + onEdit?: (item: SourceStack) => void; + onDelete?: (item: SourceStack) => void; +} + +export function SourceStackSection({ + section, + items, + canEdit, + onEdit, + onDelete, +}: SourceStackSectionProps): ReactElement { + return ( +
+
+ + {section} + + +
+
+ {items.map((item) => ( + + ))} +
+
+ ); +} diff --git a/packages/shared/src/components/squads/stack/SquadStack.tsx b/packages/shared/src/components/squads/stack/SquadStack.tsx new file mode 100644 index 0000000000..7363dea92d --- /dev/null +++ b/packages/shared/src/components/squads/stack/SquadStack.tsx @@ -0,0 +1,170 @@ +import type { ReactElement } from 'react'; +import React, { useState, useCallback } from 'react'; +import type { Squad } from '../../../graphql/sources'; +import { useSourceStack } from '../../../hooks/source/useSourceStack'; +import { + Typography, + TypographyType, + TypographyColor, +} from '../../typography/Typography'; +import { Button, ButtonSize, ButtonVariant } from '../../buttons/Button'; +import { PlusIcon } from '../../icons'; +import { SourceStackItem } from './SourceStackItem'; +import { SourceStackModal } from './SourceStackModal'; +import type { + SourceStack, + AddSourceStackInput, +} from '../../../graphql/source/sourceStack'; +import { useToastNotification } from '../../../hooks/useToastNotification'; +import { usePrompt } from '../../../hooks/usePrompt'; + +interface SquadStackProps { + squad: Squad; +} + +export function SquadStack({ squad }: SquadStackProps): ReactElement | null { + const { stackItems, canEdit, add, update, remove } = useSourceStack(squad); + const { displayToast } = useToastNotification(); + const { showPrompt } = usePrompt(); + + const [isModalOpen, setIsModalOpen] = useState(false); + const [editingItem, setEditingItem] = useState(null); + + const handleAdd = useCallback( + async (input: AddSourceStackInput) => { + try { + await add(input); + displayToast('Added to squad stack'); + } catch (error) { + displayToast('Failed to add item'); + throw error; + } + }, + [add, displayToast], + ); + + const handleEdit = useCallback((item: SourceStack) => { + setEditingItem(item); + setIsModalOpen(true); + }, []); + + const handleUpdate = useCallback( + async (input: AddSourceStackInput) => { + if (!editingItem) { + return; + } + try { + await update({ + id: editingItem.id, + input: { + title: input.title, + }, + }); + displayToast('Stack item updated'); + } catch (error) { + displayToast('Failed to update item'); + throw error; + } + }, + [editingItem, update, displayToast], + ); + + const handleDelete = useCallback( + async (item: SourceStack) => { + const displayTitle = item.title ?? item.tool.title; + const confirmed = await showPrompt({ + title: 'Remove from stack?', + description: `Are you sure you want to remove "${displayTitle}" from the squad stack?`, + okButton: { title: 'Remove', variant: ButtonVariant.Primary }, + }); + if (!confirmed) { + return; + } + + try { + await remove(item.id); + displayToast('Removed from squad stack'); + } catch (error) { + displayToast('Failed to remove item'); + } + }, + [remove, displayToast, showPrompt], + ); + + const handleCloseModal = useCallback(() => { + setIsModalOpen(false); + setEditingItem(null); + }, []); + + const hasItems = stackItems.length > 0; + + if (!hasItems && !canEdit) { + return null; + } + + return ( +
+
+ + Stack & Tools + + {canEdit && ( + + )} +
+ + {hasItems ? ( +
+ {stackItems.map((item) => ( + + ))} +
+ ) : ( + canEdit && ( +
+ + Share your squad's stack & tools + + +
+ ) + )} + + {isModalOpen && ( + + )} +
+ ); +} diff --git a/packages/shared/src/components/squads/stack/index.ts b/packages/shared/src/components/squads/stack/index.ts new file mode 100644 index 0000000000..ff3e731e21 --- /dev/null +++ b/packages/shared/src/components/squads/stack/index.ts @@ -0,0 +1,4 @@ +export { SquadStack } from './SquadStack'; +export { SourceStackSection } from './SourceStackSection'; +export { SourceStackItem } from './SourceStackItem'; +export { SourceStackModal } from './SourceStackModal'; diff --git a/packages/shared/src/graphql/source/sourceStack.ts b/packages/shared/src/graphql/source/sourceStack.ts new file mode 100644 index 0000000000..70f7b5abba --- /dev/null +++ b/packages/shared/src/graphql/source/sourceStack.ts @@ -0,0 +1,153 @@ +import { gql } from 'graphql-request'; +import type { Connection } from '../common'; +import { gqlClient } from '../common'; +import type { DatasetTool } from '../user/userStack'; + +export interface SourceStackCreatedBy { + id: string; + name: string; + image: string; +} + +export interface SourceStack { + id: string; + tool: DatasetTool; + position: number; + icon: string | null; + title: string | null; + createdAt: string; + createdBy: SourceStackCreatedBy; +} + +export interface AddSourceStackInput { + title: string; +} + +export interface UpdateSourceStackInput { + icon?: string; + title?: string; +} + +export interface ReorderSourceStackInput { + id: string; + position: number; +} + +const SOURCE_STACK_FRAGMENT = gql` + fragment SourceStackFragment on SourceStack { + id + position + icon + title + createdAt + tool { + id + title + faviconUrl + } + createdBy { + id + name + image + } + } +`; + +export const SOURCE_STACK_QUERY = gql` + query SourceStack($sourceId: ID!, $first: Int, $after: String) { + sourceStack(sourceId: $sourceId, first: $first, after: $after) { + edges { + node { + ...SourceStackFragment + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + ${SOURCE_STACK_FRAGMENT} +`; + +const ADD_SOURCE_STACK_MUTATION = gql` + mutation AddSourceStack($sourceId: ID!, $input: AddSourceStackInput!) { + addSourceStack(sourceId: $sourceId, input: $input) { + ...SourceStackFragment + } + } + ${SOURCE_STACK_FRAGMENT} +`; + +const UPDATE_SOURCE_STACK_MUTATION = gql` + mutation UpdateSourceStack($id: ID!, $input: UpdateSourceStackInput!) { + updateSourceStack(id: $id, input: $input) { + ...SourceStackFragment + } + } + ${SOURCE_STACK_FRAGMENT} +`; + +const DELETE_SOURCE_STACK_MUTATION = gql` + mutation DeleteSourceStack($id: ID!) { + deleteSourceStack(id: $id) { + _ + } + } +`; + +const REORDER_SOURCE_STACK_MUTATION = gql` + mutation ReorderSourceStack( + $sourceId: ID! + $items: [ReorderSourceStackInput!]! + ) { + reorderSourceStack(sourceId: $sourceId, items: $items) { + ...SourceStackFragment + } + } + ${SOURCE_STACK_FRAGMENT} +`; + +export const getSourceStack = async ( + sourceId: string, + first = 50, +): Promise> => { + const result = await gqlClient.request<{ + sourceStack: Connection; + }>(SOURCE_STACK_QUERY, { sourceId, first }); + return result.sourceStack; +}; + +export const addSourceStack = async ( + sourceId: string, + input: AddSourceStackInput, +): Promise => { + const result = await gqlClient.request<{ + addSourceStack: SourceStack; + }>(ADD_SOURCE_STACK_MUTATION, { sourceId, input }); + return result.addSourceStack; +}; + +export const updateSourceStack = async ( + id: string, + input: UpdateSourceStackInput, +): Promise => { + const result = await gqlClient.request<{ + updateSourceStack: SourceStack; + }>(UPDATE_SOURCE_STACK_MUTATION, { id, input }); + return result.updateSourceStack; +}; + +export const deleteSourceStack = async (id: string): Promise => { + await gqlClient.request(DELETE_SOURCE_STACK_MUTATION, { id }); +}; + +export const reorderSourceStack = async ( + sourceId: string, + items: ReorderSourceStackInput[], +): Promise => { + const result = await gqlClient.request<{ + reorderSourceStack: SourceStack[]; + }>(REORDER_SOURCE_STACK_MUTATION, { sourceId, items }); + return result.reorderSourceStack; +}; diff --git a/packages/shared/src/hooks/source/index.ts b/packages/shared/src/hooks/source/index.ts index b09f7f2403..8147f2ee00 100644 --- a/packages/shared/src/hooks/source/index.ts +++ b/packages/shared/src/hooks/source/index.ts @@ -1,2 +1,3 @@ export * from './useSourceActions'; export * from './useSourceActionsNotify'; +export * from './useSourceStack'; diff --git a/packages/shared/src/hooks/source/useSourceStack.ts b/packages/shared/src/hooks/source/useSourceStack.ts new file mode 100644 index 0000000000..617e4f83bc --- /dev/null +++ b/packages/shared/src/hooks/source/useSourceStack.ts @@ -0,0 +1,86 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useMemo, useCallback } from 'react'; +import type { Squad } from '../../graphql/sources'; +import type { + AddSourceStackInput, + UpdateSourceStackInput, + ReorderSourceStackInput, +} from '../../graphql/source/sourceStack'; +import { + getSourceStack, + addSourceStack, + updateSourceStack, + deleteSourceStack, + reorderSourceStack, +} from '../../graphql/source/sourceStack'; +import { generateQueryKey, RequestKey, StaleTime } from '../../lib/query'; +import { verifyPermission } from '../../graphql/squads'; +import { SourcePermissions as SourcePermissionsEnum } from '../../graphql/sources'; + +export function useSourceStack(squad: Squad | null) { + const queryClient = useQueryClient(); + const canEdit = squad + ? verifyPermission(squad, SourcePermissionsEnum.Edit) + : false; + + const queryKey = generateQueryKey(RequestKey.SourceStack, null, squad?.id); + + const query = useQuery({ + queryKey, + queryFn: () => getSourceStack(squad?.id as string), + staleTime: StaleTime.Default, + enabled: !!squad?.id, + }); + + const stackItems = useMemo( + () => query.data?.edges?.map(({ node }) => node) ?? [], + [query.data], + ); + + const invalidateQuery = useCallback(() => { + queryClient.invalidateQueries({ queryKey }); + }, [queryClient, queryKey]); + + const addMutation = useMutation({ + mutationFn: (input: AddSourceStackInput) => + addSourceStack(squad?.id as string, input), + onSuccess: invalidateQuery, + }); + + const updateMutation = useMutation({ + mutationFn: ({ + id, + input, + }: { + id: string; + input: UpdateSourceStackInput; + }) => updateSourceStack(id, input), + onSuccess: invalidateQuery, + }); + + const deleteMutation = useMutation({ + mutationFn: (id: string) => deleteSourceStack(id), + onSuccess: invalidateQuery, + }); + + const reorderMutation = useMutation({ + mutationFn: (items: ReorderSourceStackInput[]) => + reorderSourceStack(squad?.id as string, items), + onSuccess: invalidateQuery, + }); + + return { + ...query, + stackItems, + canEdit, + queryKey, + add: addMutation.mutateAsync, + update: updateMutation.mutateAsync, + remove: deleteMutation.mutateAsync, + reorder: reorderMutation.mutateAsync, + isAdding: addMutation.isPending, + isUpdating: updateMutation.isPending, + isDeleting: deleteMutation.isPending, + isReordering: reorderMutation.isPending, + }; +} diff --git a/packages/shared/src/lib/query.ts b/packages/shared/src/lib/query.ts index d2a9e630cb..838d236596 100644 --- a/packages/shared/src/lib/query.ts +++ b/packages/shared/src/lib/query.ts @@ -231,6 +231,7 @@ export enum RequestKey { Autocomplete = 'autocomplete', UserExperience = 'user_experience', UserStack = 'user_stack', + SourceStack = 'source_stack', StackSearch = 'stack_search', UserHotTakes = 'user_hot_takes', UserTools = 'user_tools',