Skip to content
Merged
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
4 changes: 4 additions & 0 deletions packages/shared/src/components/squads/SquadPageHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
TypographyType,
} from '../typography/Typography';
import { ClickableText } from '../buttons/ClickableText';
import { SquadStack } from './stack/SquadStack';

interface SquadPageHeaderProps {
squad: Squad;
Expand Down Expand Up @@ -178,6 +179,9 @@ export function SquadPageHeader({
</Button>
)}
</div>
<div className={classNames('w-full', MAX_WIDTH)}>
<SquadStack squad={squad} />
</div>
<EnableNotification
contentName={squad.name}
source={NotificationPromptSource.SquadPage}
Expand Down
83 changes: 83 additions & 0 deletions packages/shared/src/components/squads/stack/SourceStackItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import type { ReactElement } from 'react';
import React from 'react';
import classNames from 'classnames';
import type { SourceStack } from '../../../graphql/source/sourceStack';
import {
Typography,
TypographyType,
TypographyColor,
} from '../../typography/Typography';
import { Button, ButtonSize, ButtonVariant } from '../../buttons/Button';
import { EditIcon, PlusIcon, TrashIcon } from '../../icons';

interface SourceStackItemProps {
item: SourceStack;
canEdit: boolean;
onEdit?: (item: SourceStack) => void;
onDelete?: (item: SourceStack) => void;
}

export function SourceStackItem({
item,
canEdit,
onEdit,
onDelete,
}: SourceStackItemProps): ReactElement {
const { tool } = item;
const title = item.title ?? tool.title;

return (
<div
className={classNames(
'group relative flex items-center justify-between gap-3 rounded-12 border border-border-subtlest-tertiary px-3 pb-2.5 pt-2',
'hover:border-border-subtlest-secondary',
)}
>
<div className="flex items-center gap-2">
{tool.faviconUrl ? (
<img
src={tool.faviconUrl}
alt=""
className="rounded size-6 flex-shrink-0"
/>
) : (
<PlusIcon className="size-6 flex-shrink-0 text-text-tertiary" />
)}
{!!title && (
<div className="flex min-w-0 flex-1 flex-col">
<Typography
type={TypographyType.Callout}
color={TypographyColor.Primary}
bold
truncate
>
{title}
</Typography>
</div>
)}
</div>
{canEdit && (
<div className="flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
{onEdit && (
<Button
variant={ButtonVariant.Tertiary}
size={ButtonSize.XSmall}
icon={<EditIcon />}
onClick={() => onEdit(item)}
aria-label="Edit stack item"
/>
)}
{onDelete && (
<Button
variant={ButtonVariant.Tertiary}
size={ButtonSize.XSmall}
icon={<TrashIcon />}
onClick={() => onDelete(item)}
aria-label="Delete stack item"
/>
)}
</div>
)}
</div>
);
}
188 changes: 188 additions & 0 deletions packages/shared/src/components/squads/stack/SourceStackModal.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof sourceStackFormSchema>;

type SourceStackModalProps = Omit<ModalProps, 'children'> & {
onSubmit: (input: AddSourceStackInput) => Promise<void>;
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<SourceStackFormData>({
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 (
<FormProvider {...methods}>
<Modal
formProps={{
form: 'source_stack_form',
title: (
<div className="px-4">
<ModalHeader.Title className="typo-title3">
{isEditing ? 'Edit Stack Item' : 'Add to Squad Stack'}
</ModalHeader.Title>
</div>
),
rightButtonProps: {
variant: ButtonVariant.Primary,
disabled: !canSubmit || isSubmitting,
loading: isSubmitting,
},
copy: { right: isEditing ? 'Save' : 'Add' },
}}
kind={Modal.Kind.FlexibleCenter}
size={Modal.Size.Small}
{...rest}
>
<form onSubmit={onFormSubmit} id="source_stack_form" className="w-full">
<ModalHeader showCloseButton={!isMobile}>
<ModalHeader.Title className="typo-title3">
{isEditing ? 'Edit Stack Item' : 'Add to Squad Stack'}
</ModalHeader.Title>
</ModalHeader>
<Modal.Body className="flex flex-col gap-4">
{/* Title with autocomplete */}
<div className="relative">
<TextField
{...register('title')}
autoComplete="off"
autoFocus
inputId="stackTitle"
label="Technology or tool"
maxLength={255}
valid={!errors.title}
hint={errors.title?.message}
disabled={isEditing}
onChange={(e) => {
setValue('title', e.target.value);
if (!isEditing) {
setShowSuggestions(true);
}
}}
onFocus={() => {
if (!isEditing) {
setShowSuggestions(true);
}
}}
/>
{!isEditing && showSuggestions && title.trim() && (
<div className="absolute left-0 right-0 top-full z-1 mt-1 max-h-48 overflow-auto rounded-12 border border-border-subtlest-tertiary bg-background-default shadow-2">
{filteredSuggestions.map((suggestion) => (
<button
key={suggestion.id}
type="button"
className="flex w-full items-center gap-2 px-4 py-2 text-left hover:bg-surface-hover"
onClick={() => handleSelectSuggestion(suggestion)}
>
{suggestion.faviconUrl ? (
<img
src={suggestion.faviconUrl}
alt=""
className="rounded size-4"
/>
) : (
<PlusIcon className="size-4 text-text-tertiary" />
)}
<span className="typo-callout">{suggestion.title}</span>
</button>
))}
{!filteredSuggestions.some(
(s) => s.title.toLowerCase() === title.trim().toLowerCase(),
) && (
<button
type="button"
className="flex w-full items-center gap-2 px-4 py-2 text-left hover:bg-surface-hover"
onClick={() => {
setShowSuggestions(false);
}}
>
<PlusIcon className="size-4 text-text-tertiary" />
<span className="typo-callout">{title.trim()}</span>
</button>
)}
</div>
)}
</div>

{!isMobile && (
<Button
type="submit"
disabled={!canSubmit || isSubmitting}
loading={isSubmitting}
variant={ButtonVariant.Primary}
>
{isEditing ? 'Save changes' : 'Add to stack'}
</Button>
)}
</Modal.Body>
</form>
</Modal>
</FormProvider>
);
}
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<Typography
type={TypographyType.Callout}
color={TypographyColor.Secondary}
bold
>
{section}
</Typography>
<Pill
label={String(items.length)}
size={PillSize.Small}
className="border border-border-subtlest-tertiary text-text-quaternary"
/>
</div>
<div className="flex flex-wrap gap-2">
{items.map((item) => (
<SourceStackItem
key={item.id}
item={item}
canEdit={canEdit}
onEdit={onEdit}
onDelete={onDelete}
/>
))}
</div>
</div>
);
}
Loading