diff --git a/core/app/(user)/forgot/ForgotForm.tsx b/core/app/(user)/forgot/ForgotForm.tsx index fa47f7dff..0b926d41a 100644 --- a/core/app/(user)/forgot/ForgotForm.tsx +++ b/core/app/(user)/forgot/ForgotForm.tsx @@ -1,6 +1,8 @@ "use client"; import React, { FormEvent, useState } from "react"; -import { Button, Icon } from "ui"; +import { Button } from "ui/button"; +import { Loader2 } from "ui/icon"; + import { supabase } from "lib/supabase"; import { useEnvContext } from "next-runtime-env"; @@ -49,9 +51,7 @@ export default function ForgotForm() { {failure && ( diff --git a/core/app/(user)/login/LoginForm.tsx b/core/app/(user)/login/LoginForm.tsx index 07b3dfa71..4774b9248 100644 --- a/core/app/(user)/login/LoginForm.tsx +++ b/core/app/(user)/login/LoginForm.tsx @@ -1,6 +1,6 @@ "use client"; import React, { useState, FormEvent } from "react"; -import { Button } from "ui"; +import { Button } from "ui/button"; import { supabase } from "lib/supabase"; import Link from "next/link"; import { useRouter } from "next/navigation"; @@ -38,7 +38,7 @@ export default function LoginForm() { router.push("/settings"); } } - } + }; return (
diff --git a/core/app/(user)/reset/ResetForm.tsx b/core/app/(user)/reset/ResetForm.tsx index eec2f397f..3d20f3283 100644 --- a/core/app/(user)/reset/ResetForm.tsx +++ b/core/app/(user)/reset/ResetForm.tsx @@ -1,6 +1,7 @@ "use client"; import React, { useState, FormEvent } from "react"; -import { Button, Icon } from "ui"; +import { Button } from "ui/button"; +import { Loader2 } from "ui/icon"; import { formatSupabaseError, supabase } from "lib/supabase"; import { useRouter } from "next/navigation"; @@ -57,7 +58,7 @@ export default function ResetForm() { {error && ( diff --git a/core/app/(user)/settings/SettingsForm.tsx b/core/app/(user)/settings/SettingsForm.tsx index 7bb218b71..23583845f 100644 --- a/core/app/(user)/settings/SettingsForm.tsx +++ b/core/app/(user)/settings/SettingsForm.tsx @@ -4,7 +4,9 @@ import { supabase } from "lib/supabase"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { FormEvent, useState } from "react"; -import { Avatar, AvatarFallback, AvatarImage, Button, Icon } from "ui"; +import { Button } from "ui/button"; +import { Avatar, AvatarFallback, AvatarImage } from "ui/avatar"; +import { Loader2 } from "ui/icon"; import LogoutButton from "~/app/components/LogoutButton"; import { UserPutBody, UserSettings } from "~/lib/types"; import { useEnvContext } from "next-runtime-env"; @@ -162,7 +164,7 @@ export default function SettingsForm({ {!resetSuccess && ( )} {resetSuccess && ( diff --git a/core/app/(user)/signup/SignupForm.tsx b/core/app/(user)/signup/SignupForm.tsx index 0d6d6389f..9ade935c2 100644 --- a/core/app/(user)/signup/SignupForm.tsx +++ b/core/app/(user)/signup/SignupForm.tsx @@ -1,6 +1,6 @@ "use client"; import React, { useState, FormEvent } from "react"; -import { Button } from "ui"; +import { Button } from "ui/button"; import { UserPostBody } from "~/lib/types"; export default function SignupForm() { diff --git a/core/app/c/[communitySlug]/CommunitySwitcher.tsx b/core/app/c/[communitySlug]/CommunitySwitcher.tsx index aa538d6c9..4f8d7eb75 100644 --- a/core/app/c/[communitySlug]/CommunitySwitcher.tsx +++ b/core/app/c/[communitySlug]/CommunitySwitcher.tsx @@ -1,13 +1,11 @@ import Link from "next/link"; +import { Avatar, AvatarFallback, AvatarImage } from "ui/avatar"; import { - Avatar, - AvatarFallback, - AvatarImage, DropdownMenu, DropdownMenuItem, DropdownMenuContent, DropdownMenuTrigger, -} from "ui"; +} from "ui/dropdown-menu"; import { CommunityData } from "./layout"; type Props = { diff --git a/core/app/c/[communitySlug]/LoginSwitcher.tsx b/core/app/c/[communitySlug]/LoginSwitcher.tsx index cf82d75a7..0853ebf2f 100644 --- a/core/app/c/[communitySlug]/LoginSwitcher.tsx +++ b/core/app/c/[communitySlug]/LoginSwitcher.tsx @@ -1,5 +1,6 @@ import { getLoginData } from "~/lib/auth/loginData"; -import { Avatar, AvatarFallback, AvatarImage, Button } from "ui"; +import { Avatar, AvatarFallback, AvatarImage } from "ui/avatar"; +import { Button } from "ui/button"; import LogoutButton from "../../components/LogoutButton"; import Link from "next/link"; @@ -27,7 +28,9 @@ export default async function LoginSwitcher() {
- +
diff --git a/core/app/c/[communitySlug]/integrations/IntegrationsList.tsx b/core/app/c/[communitySlug]/integrations/IntegrationsList.tsx index b963594fc..67beb51c6 100644 --- a/core/app/c/[communitySlug]/integrations/IntegrationsList.tsx +++ b/core/app/c/[communitySlug]/integrations/IntegrationsList.tsx @@ -1,7 +1,9 @@ "use client"; import NextLink from "next/link"; -import { Button, Card, CardContent, CardHeader } from "ui"; + +import { Button } from "ui/button"; +import { Card, CardContent, CardHeader } from "ui/card"; import { IntegrationData } from "./page"; import { Row, RowContent, RowFooter } from "~/app/components/Row"; diff --git a/core/app/c/[communitySlug]/pubs/PubHeader.tsx b/core/app/c/[communitySlug]/pubs/PubHeader.tsx index 185423274..83e571c25 100644 --- a/core/app/c/[communitySlug]/pubs/PubHeader.tsx +++ b/core/app/c/[communitySlug]/pubs/PubHeader.tsx @@ -1,5 +1,5 @@ import Link from "next/link"; -import { Button } from "ui"; +import { Button } from "ui/button"; type Props = {}; diff --git a/core/app/c/[communitySlug]/pubs/[pubId]/page.tsx b/core/app/c/[communitySlug]/pubs/[pubId]/page.tsx index 0641e404f..670c781b2 100644 --- a/core/app/c/[communitySlug]/pubs/[pubId]/page.tsx +++ b/core/app/c/[communitySlug]/pubs/[pubId]/page.tsx @@ -1,19 +1,11 @@ import { Prisma, PubField, PubFieldSchema, PubValue } from "@prisma/client"; import { AnySchema, JSONSchemaType } from "ajv"; import Link from "next/link"; -import { - Avatar, - AvatarFallback, - AvatarImage, - Button, - CardContent, - CardHeader, - CardTitle, - HoverCard, - HoverCardContent, - HoverCardTrigger, - Separator, -} from "ui"; +import { Avatar, AvatarFallback, AvatarImage } from "ui/avatar"; +import { Button } from "ui/button"; +import { CardContent, CardHeader, CardTitle } from "ui/card"; +import { HoverCard, HoverCardContent, HoverCardTrigger } from "ui/hover-card"; +import { Separator } from "ui/separator"; import IntegrationActions from "~/app/components/IntegrationActions"; import { PubTitle } from "~/app/components/PubTitle"; diff --git a/core/app/c/[communitySlug]/stages/components/Assign.tsx b/core/app/c/[communitySlug]/stages/components/Assign.tsx index eaff45b1f..8ef30806d 100644 --- a/core/app/c/[communitySlug]/stages/components/Assign.tsx +++ b/core/app/c/[communitySlug]/stages/components/Assign.tsx @@ -1,20 +1,12 @@ "use client"; import Image from "next/image"; import React from "react"; -import { - Button, - Card, - CardContent, - CardFooter, - CardTitle, - Dialog, - DialogContent, - DialogTrigger, - Popover, - PopoverContent, - PopoverTrigger, - useToast, -} from "ui"; +import { Button } from "ui/button"; +import { Card, CardContent, CardFooter, CardTitle } from "ui/card"; +import { Dialog, DialogContent, DialogTrigger } from "ui/dialog"; +import { Popover, PopoverContent, PopoverTrigger } from "ui/popover"; +import { useToast } from "ui/use-toast"; + import { PermissionPayloadUser, PubPayload, diff --git a/core/app/c/[communitySlug]/stages/components/Move.tsx b/core/app/c/[communitySlug]/stages/components/Move.tsx index 1fa604a6d..7a43b3d0b 100644 --- a/core/app/c/[communitySlug]/stages/components/Move.tsx +++ b/core/app/c/[communitySlug]/stages/components/Move.tsx @@ -1,5 +1,7 @@ "use client"; -import { Button, Popover, PopoverContent, PopoverTrigger, useToast } from "ui"; +import { Button } from "ui/button"; +import { useToast } from "ui/use-toast"; +import { Popover, PopoverContent, PopoverTrigger } from "ui/popover"; import { PubPayload, StagePayload, StagePayloadMoveConstraintDestination } from "~/lib/types"; import { move } from "./lib/actions"; diff --git a/core/app/c/[communitySlug]/stages/components/StageList.tsx b/core/app/c/[communitySlug]/stages/components/StageList.tsx index 1204a231e..0dc39dcd0 100644 --- a/core/app/c/[communitySlug]/stages/components/StageList.tsx +++ b/core/app/c/[communitySlug]/stages/components/StageList.tsx @@ -2,7 +2,7 @@ import Link from "next/link"; import { Fragment } from "react"; -import { Button } from "ui"; +import { Button } from "ui/button"; import PubRow from "~/app/components/PubRow"; import { getPubUsers } from "~/lib/permissions"; import { StagesById, StagePayload, UserLoginData } from "~/lib/types"; diff --git a/core/app/c/[communitySlug]/stages/manage/StageEditor.tsx b/core/app/c/[communitySlug]/stages/manage/StageEditor.tsx index e0d666f11..48e709874 100644 --- a/core/app/c/[communitySlug]/stages/manage/StageEditor.tsx +++ b/core/app/c/[communitySlug]/stages/manage/StageEditor.tsx @@ -1,5 +1,6 @@ import { useState } from "react"; -import { Tabs, TabsContent, TabsList, TabsTrigger, toast } from "ui"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "ui/tabs"; +import { toast } from "ui/use-toast"; import { StageFormSchema, moveConstraintSourcesForStage } from "~/lib/stages"; import { DeepPartial, StagesById, StagePayload } from "~/lib/types"; import StageForm from "./StageForm"; diff --git a/core/app/c/[communitySlug]/stages/manage/StageForm.tsx b/core/app/c/[communitySlug]/stages/manage/StageForm.tsx index 84d953f61..bc8b23e55 100644 --- a/core/app/c/[communitySlug]/stages/manage/StageForm.tsx +++ b/core/app/c/[communitySlug]/stages/manage/StageForm.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect } from "react"; import { useForm } from "react-hook-form"; +import { Button } from "ui/button"; import { - Button, Form, FormControl, FormDescription, @@ -9,9 +9,9 @@ import { FormItem, FormLabel, FormMessage, - Icon, - Input, -} from "ui"; +} from "ui/form"; +import * as Icon from "ui/icon"; +import { Input } from "ui/input"; import { assert } from "utils"; import { StageFormSchema } from "~/lib/stages"; import { StagePayload, StagesById, DeepPartial } from "~/lib/types"; diff --git a/core/app/c/[communitySlug]/stages/manage/StageManagement.tsx b/core/app/c/[communitySlug]/stages/manage/StageManagement.tsx index 012aae1e7..e6ca67bc9 100644 --- a/core/app/c/[communitySlug]/stages/manage/StageManagement.tsx +++ b/core/app/c/[communitySlug]/stages/manage/StageManagement.tsx @@ -1,7 +1,7 @@ "use client"; import { useState } from "react"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "ui"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "ui/tabs"; import StageEditor from "./StageEditor"; import { StagePayload, StagesById } from "~/lib/types"; diff --git a/core/app/c/[communitySlug]/types/TypeBlock.tsx b/core/app/c/[communitySlug]/types/TypeBlock.tsx index b243d6c7c..52b7efbd1 100644 --- a/core/app/c/[communitySlug]/types/TypeBlock.tsx +++ b/core/app/c/[communitySlug]/types/TypeBlock.tsx @@ -1,6 +1,7 @@ "use client"; import { useState } from "react"; -import { Button, Card, CardContent } from "ui"; +import { Button } from "ui/button"; +import { Card, CardContent } from "ui/card"; import { TypesData } from "./page"; type Props = { type: NonNullable[number] }; diff --git a/core/app/components/IntegrationActions.tsx b/core/app/components/IntegrationActions.tsx index eb8e63328..ddf6a05be 100644 --- a/core/app/components/IntegrationActions.tsx +++ b/core/app/components/IntegrationActions.tsx @@ -1,4 +1,4 @@ -import { Popover, PopoverTrigger, Button, PopoverContent } from "ui"; +import { Button } from "ui/button"; import { PubPayload } from "~/lib/types"; type Props = { diff --git a/core/app/components/LogoutButton.tsx b/core/app/components/LogoutButton.tsx index 550a44292..5dab03a8a 100644 --- a/core/app/components/LogoutButton.tsx +++ b/core/app/components/LogoutButton.tsx @@ -1,6 +1,6 @@ "use client"; import { supabase } from "~/lib/supabase"; -import { Button } from "ui"; +import { Button } from "ui/button"; import { useRouter } from "next/navigation"; export default function LogoutButton() { diff --git a/core/app/components/PubRow.tsx b/core/app/components/PubRow.tsx index 2b3e1496e..a5bba352a 100644 --- a/core/app/components/PubRow.tsx +++ b/core/app/components/PubRow.tsx @@ -2,7 +2,8 @@ import Link from "next/link"; import React, { Fragment } from "react"; -import { Button, Collapsible, CollapsibleContent, CollapsibleTrigger } from "ui"; +import { Button } from "ui/button"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "ui/collapsible"; import { cn } from "utils"; import { PubPayload } from "~/lib/types"; import IntegrationActions from "./IntegrationActions"; @@ -48,7 +49,10 @@ const ChildHierarchy = ({ pub }: { pub: PubPayload["children"][number] }) => { {group.pubType.name} - + {getTitle(child)} diff --git a/core/app/components/Row.tsx b/core/app/components/Row.tsx index 31e642034..1ce0d9a36 100644 --- a/core/app/components/Row.tsx +++ b/core/app/components/Row.tsx @@ -1,7 +1,7 @@ "use client"; import { PropsWithChildren } from "react"; -import { Card, CardContent, CardFooter, CardHeader } from "ui"; +import { Card, CardContent, CardFooter, CardHeader } from "ui/card"; import { cn } from "utils"; type Props = PropsWithChildren<{ className?: string }>; diff --git a/core/app/layout.tsx b/core/app/layout.tsx index 6a3f4ad85..029e332da 100644 --- a/core/app/layout.tsx +++ b/core/app/layout.tsx @@ -1,4 +1,4 @@ -import { Toaster } from "ui"; +import { Toaster } from "ui/toaster"; import "ui/styles.css"; import InitClient from "./InitClient"; import { PublicEnvProvider } from "next-runtime-env"; diff --git a/integrations/evaluations/app/actions/evaluate/actions.ts b/integrations/evaluations/app/actions/evaluate/actions.ts index 9182a2802..1a71159e1 100644 --- a/integrations/evaluations/app/actions/evaluate/actions.ts +++ b/integrations/evaluations/app/actions/evaluate/actions.ts @@ -3,7 +3,11 @@ import { PubValues } from "@pubpub/sdk"; import { revalidatePath } from "next/cache"; import { expect } from "utils"; -import { sendSubmittedNotificationEmail, unscheduleNoSubmitNotificationEmail } from "~/lib/emails"; +import { + sendSubmittedNotificationEmail, + unscheduleAllDeadlineReminderEmails, + unscheduleNoSubmitNotificationEmail, +} from "~/lib/emails"; import { getInstanceConfig, getInstanceState, setInstanceState } from "~/lib/instance"; import { client } from "~/lib/pubpub"; import { cookie } from "~/lib/request"; @@ -37,8 +41,10 @@ export const submit = async (instanceId: string, pubId: string, values: PubValue evaluationPubId: pub.id, }; await setInstanceState(instanceId, pubId, instanceState); - // Unschedule no-submit notification email. + // Unschedule no-submit notification email for manager. await unscheduleNoSubmitNotificationEmail(instanceId, pubId, evaluator); + // unschedule dealine reminder emails. + await unscheduleAllDeadlineReminderEmails(instanceId, pubId, evaluator); // Immediately send submitted notification email. await sendSubmittedNotificationEmail(instanceId, instanceConfig, pubId, evaluator); revalidatePath("/"); diff --git a/integrations/evaluations/app/actions/evaluate/evaluate.tsx b/integrations/evaluations/app/actions/evaluate/evaluate.tsx index f40b24825..9c675f979 100644 --- a/integrations/evaluations/app/actions/evaluate/evaluate.tsx +++ b/integrations/evaluations/app/actions/evaluate/evaluate.tsx @@ -7,12 +7,16 @@ import Ajv from "ajv"; import { fullFormats } from "ajv-formats/dist/formats"; import { useEffect, useMemo } from "react"; import { useForm } from "react-hook-form"; -import { Button, Form, Icon, useLocalStorage, useToast } from "ui"; import { Process } from "~/lib/components/Process"; import { Research } from "~/lib/components/Research"; import { EvaluatorWhoAccepted, InstanceConfig } from "~/lib/types"; import { submit, upload } from "./actions"; -import { calculateDeadline } from "~/lib/emails"; +import { getDeadline } from "~/lib/emails"; +import { useToast } from "ui/use-toast"; +import { useLocalStorage } from "ui/hooks"; +import { Form } from "ui/form"; +import { Button } from "ui/button"; +import { Loader2 } from "ui/icon"; type Props = { instanceId: string; @@ -97,15 +101,7 @@ export function Evaluate(props: Props) { const submissionUrl = pub.values["unjournal:url"] as string; const submissionTitle = pub.values[props.instanceConfig.titleFieldSlug] as string; const submissionAbstract = pub.values["unjournal:description"] as string; - const deadline = props.evaluator.deadline - ? new Date(props.evaluator.deadline) - : calculateDeadline( - { - deadlineLength: props.instanceConfig.deadlineLength, - deadlineUnit: props.instanceConfig.deadlineUnit, - }, - new Date(props.evaluator.acceptedAt) - ); + const deadline = getDeadline(props.instanceConfig, props.evaluator); return ( <> @@ -124,7 +120,7 @@ export function Evaluate(props: Props) { {formFieldsFromSchema} diff --git a/integrations/evaluations/app/actions/manage/EvaluatorInviteForm.tsx b/integrations/evaluations/app/actions/manage/EvaluatorInviteForm.tsx index 91981c810..26deec554 100644 --- a/integrations/evaluations/app/actions/manage/EvaluatorInviteForm.tsx +++ b/integrations/evaluations/app/actions/manage/EvaluatorInviteForm.tsx @@ -4,21 +4,6 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { GetPubResponseBody } from "@pubpub/sdk"; import React, { useCallback } from "react"; import { useFieldArray, useForm } from "react-hook-form"; -import { - Button, - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, - Form, - FormDescription, - FormItem, - FormLabel, - Icon, - useToast, -} from "ui"; import { cn } from "utils"; import { EmailTemplate, Evaluator, InstanceConfig, hasUser, isInvited, isSaved } from "~/lib/types"; import { EvaluatorInviteFormInviteButton } from "./EvaluatorInviteFormInviteButton"; @@ -26,6 +11,11 @@ import { EvaluatorInviteFormSaveButton } from "./EvaluatorInviteFormSaveButton"; import { EvaluatorInviteRow } from "./EvaluatorInviteRow"; import * as actions from "./actions"; import { InviteFormEvaluator, InviteFormSchema } from "./types"; +import { useToast } from "ui/use-toast"; +import { Form, FormDescription, FormItem, FormLabel } from "ui/form"; +import { Button } from "ui/button"; +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "ui/card"; +import { Loader2, Plus } from "ui/icon"; type Props = { evaluators: Evaluator[]; @@ -223,7 +213,7 @@ export function EvaluatorInviteForm(props: Props) { /> ))} @@ -235,7 +225,7 @@ export function EvaluatorInviteForm(props: Props) {
{form.formState.isSubmitting && ( - + )} onSubmit(values, true))} diff --git a/integrations/evaluations/app/actions/manage/EvaluatorInviteFormInviteButton.tsx b/integrations/evaluations/app/actions/manage/EvaluatorInviteFormInviteButton.tsx index 8dbf7eb2e..c8306b238 100644 --- a/integrations/evaluations/app/actions/manage/EvaluatorInviteFormInviteButton.tsx +++ b/integrations/evaluations/app/actions/manage/EvaluatorInviteFormInviteButton.tsx @@ -1,5 +1,7 @@ import { useFormState, useWatch } from "react-hook-form"; -import { Button, Icon } from "ui"; +import { Button } from "ui/button"; +import { Send } from "ui/icon"; + import { InviteFormSchema } from "./types"; export type EvaluatorInviteFormInviteButtonProps = { @@ -16,7 +18,7 @@ export function EvaluatorInviteFormInviteButton(props: EvaluatorInviteFormInvite !form.evaluators?.some((evaluator) => evaluator.selected) || formState.isSubmitting } > - + Invite ); diff --git a/integrations/evaluations/app/actions/manage/EvaluatorInviteFormSaveButton.tsx b/integrations/evaluations/app/actions/manage/EvaluatorInviteFormSaveButton.tsx index 8bb912330..e3beff5fd 100644 --- a/integrations/evaluations/app/actions/manage/EvaluatorInviteFormSaveButton.tsx +++ b/integrations/evaluations/app/actions/manage/EvaluatorInviteFormSaveButton.tsx @@ -1,5 +1,5 @@ import { useFormState } from "react-hook-form"; -import { Button, Icon } from "ui"; +import { Button } from "ui/button"; export type EvaluatorInviteFormSaveButtonProps = { onClick: () => void; diff --git a/integrations/evaluations/app/actions/manage/EvaluatorInviteRow.tsx b/integrations/evaluations/app/actions/manage/EvaluatorInviteRow.tsx index f0e783ab0..4b6d2fe71 100644 --- a/integrations/evaluations/app/actions/manage/EvaluatorInviteRow.tsx +++ b/integrations/evaluations/app/actions/manage/EvaluatorInviteRow.tsx @@ -2,7 +2,10 @@ import { SuggestedMembersQuery } from "@pubpub/sdk"; import { Control, useWatch } from "react-hook-form"; -import { Button, FormControl, FormField, FormItem, FormMessage, Icon, Input } from "ui"; +import { Button } from "ui/button"; +import { Input } from "ui/input"; +import { FormControl, FormField, FormItem, FormMessage } from "ui/form"; +import { X } from "ui/icon"; import { cn } from "utils"; import { hasUser } from "~/lib/types"; import { EvaluatorInviteRowEmailDialog } from "./EvaluatorInviteRowEmailDialog"; @@ -115,7 +118,7 @@ export const EvaluatorInviteRow = (props: Props) => { )}
diff --git a/integrations/evaluations/app/actions/manage/EvaluatorInviteRowEmailDialog.tsx b/integrations/evaluations/app/actions/manage/EvaluatorInviteRowEmailDialog.tsx index cffb11cec..5de62873e 100644 --- a/integrations/evaluations/app/actions/manage/EvaluatorInviteRowEmailDialog.tsx +++ b/integrations/evaluations/app/actions/manage/EvaluatorInviteRowEmailDialog.tsx @@ -1,22 +1,18 @@ "use client"; +import { Button } from "ui/button"; import { - Button, Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, - Icon, - Input, - Textarea, -} from "ui"; +} from "ui/dialog"; +import { FormControl, FormField, FormItem, FormLabel, FormMessage } from "ui/form"; +import { Mail } from "ui/icon"; +import { Input } from "ui/input"; +import { Textarea } from "ui/textarea"; import { isInvited } from "~/lib/types"; import { InviteFormEvaluator } from "./types"; @@ -31,7 +27,7 @@ export const EvaluatorInviteRowEmailDialog = (props: EvaluatorInviteRowEmailDial diff --git a/integrations/evaluations/app/actions/manage/EvaluatorSuggestButton.tsx b/integrations/evaluations/app/actions/manage/EvaluatorSuggestButton.tsx index b337430d2..b303a280e 100644 --- a/integrations/evaluations/app/actions/manage/EvaluatorSuggestButton.tsx +++ b/integrations/evaluations/app/actions/manage/EvaluatorSuggestButton.tsx @@ -1,5 +1,6 @@ import { useTransition } from "react"; -import { Button, Icon } from "ui"; +import { Button } from "ui/button"; +import { Loader2, Wand2 } from "ui/icon"; type Props = { onClick: () => void; @@ -16,7 +17,7 @@ export const EvaluatorSuggestButton = (props: Props) => { }} disabled={pending} > - {pending ? : } + {pending ? : } ); }; diff --git a/integrations/evaluations/app/actions/manage/EvalutorInviteRowStatus.tsx b/integrations/evaluations/app/actions/manage/EvalutorInviteRowStatus.tsx index 27ead54a5..e4c050fbc 100644 --- a/integrations/evaluations/app/actions/manage/EvalutorInviteRowStatus.tsx +++ b/integrations/evaluations/app/actions/manage/EvalutorInviteRowStatus.tsx @@ -1,5 +1,5 @@ import { memo } from "react"; -import { Badge } from "ui"; +import { Badge } from "ui/badge"; import { cn } from "utils"; import { InviteStatus } from "~/lib/types"; diff --git a/integrations/evaluations/app/actions/manage/actions.ts b/integrations/evaluations/app/actions/manage/actions.ts index 7de372b5c..89d7bd141 100644 --- a/integrations/evaluations/app/actions/manage/actions.ts +++ b/integrations/evaluations/app/actions/manage/actions.ts @@ -6,12 +6,20 @@ import { expect } from "utils"; import { getInstanceConfig, getInstanceState, setInstanceState } from "~/lib/instance"; import { client } from "~/lib/pubpub"; import { cookie } from "~/lib/request"; -import { isInvited } from "~/lib/types"; import { + EvaluatorWhoAccepted, + EvaluatorWithInvite, + assertHasAccepted, + assertIsInvited, + isInvited, +} from "~/lib/types"; +import { + scheduleInvitationReminderEmail, scheduleNoReplyNotificationEmail, - scheduleReminderEmail, sendInviteEmail, -} from "../../../lib/emails"; + unscheduleAllDeadlineReminderEmails, + unscheduleAllManagerEmails, +} from "~/lib/emails"; import { InviteFormEvaluator } from "./types"; export const save = async ( @@ -71,7 +79,7 @@ export const save = async ( // Immediately send the invite email. await sendInviteEmail(instanceId, pubId, evaluator); // Scehdule a reminder email to person who was invited to evaluate. - await scheduleReminderEmail(instanceId, instanceConfig, pubId, evaluator); + await scheduleInvitationReminderEmail(instanceId, instanceConfig, pubId, evaluator); // Schedule no-reply notification email to person who invited the // evaluator. await scheduleNoReplyNotificationEmail( @@ -118,15 +126,22 @@ export const remove = async (instanceId: string, pubId: string, userId: string) const evaluation = pub.children.find( (child) => child.values[instanceConfig.evaluatorFieldSlug] === userId ); - // TODO: When an evaluator is removed, we should unschedule reminder - // email and notification email(s). + if (evaluation !== undefined) { await client.deletePub(instanceId, evaluation.id); } if (instanceState !== undefined) { + let evaluator = expect( + instanceState[userId], + `User was not invited to evaluate pub ${pubId}` + ); + assertHasAccepted(evaluator); + await unscheduleAllDeadlineReminderEmails(instanceId, pubId, evaluator); + await unscheduleAllManagerEmails(instanceId, pubId, evaluator); delete instanceState[userId]; await setInstanceState(instanceId, pubId, instanceState); } + return { success: true }; } catch (error) { return { error: error.message }; diff --git a/integrations/evaluations/app/actions/respond/actions.ts b/integrations/evaluations/app/actions/respond/actions.ts index 357dc1ba6..911934a75 100644 --- a/integrations/evaluations/app/actions/respond/actions.ts +++ b/integrations/evaluations/app/actions/respond/actions.ts @@ -8,8 +8,14 @@ import { sendDeclinedNotificationEmail, sendRequestedInfoNotification, unscheduleNoReplyNotificationEmail, - unscheduleReminderEmail, + unscheduleInvitationReminderEmail, calculateDeadline, + schedulePromptEvalBonusReminderEmail, + scheduleFinalPromptEvalBonusReminderEmail, + scheduleEvaluationReminderEmail, + scheduleFinalEvaluationReminderEmail, + scheduleFollowUpToFinalEvaluationReminderEmail, + sendNoticeOfNoSubmitEmail, } from "~/lib/emails"; import { getInstanceConfig, getInstanceState, setInstanceState } from "~/lib/instance"; import { cookie } from "~/lib/request"; @@ -39,7 +45,6 @@ export const accept = async (instanceId: string, pubId: string) => { ...evaluator, status: "accepted", acceptedAt: new Date().toString(), - deadline: new Date(Date.now()), }; const deadline = calculateDeadline( { @@ -50,16 +55,40 @@ export const accept = async (instanceId: string, pubId: string) => { ); evaluator.deadline = deadline; await setInstanceState(instanceId, pubId, instanceState); - // Unschedule reminder email. - await unscheduleReminderEmail(instanceId, pubId, evaluator); - // Unschedule no-reply notification email. + // Unschedule reminder email to evaluator. + await unscheduleInvitationReminderEmail(instanceId, pubId, evaluator); + // Unschedule no-reply notification email to community manager. await unscheduleNoReplyNotificationEmail(instanceId, pubId, evaluator); - // Immediately send accepted notification email. + // Immediately send accepted notification email to community manager. await sendAcceptedNotificationEmail(instanceId, instanceConfig, pubId, evaluator); // Immediately send accepted email to evaluator. await sendAcceptedEmail(instanceId, instanceConfig, pubId, evaluator); - // Schedule no-submit notification email. + // Schedule no-submit notification email to community manager. await scheduleNoSubmitNotificationEmail(instanceId, instanceConfig, pubId, evaluator); + + // schedule prompt evaluation email to evaluator. + await schedulePromptEvalBonusReminderEmail(instanceId, instanceConfig, pubId, evaluator); + //schedule final prompt eval email to evaluator + await scheduleFinalPromptEvalBonusReminderEmail( + instanceId, + instanceConfig, + pubId, + evaluator + ); + //schedule eval reminder email to evaluator + await scheduleEvaluationReminderEmail(instanceId, instanceConfig, pubId, evaluator); + //schedule final eval reminder email to evaluator + await scheduleFinalEvaluationReminderEmail(instanceId, instanceConfig, pubId, evaluator); + //schedule follow up to final eval reminder email to evaluator + await scheduleFollowUpToFinalEvaluationReminderEmail( + instanceId, + instanceConfig, + pubId, + evaluator + ); + // schedule no-submit notification email to evalutaor + await sendNoticeOfNoSubmitEmail(instanceId, instanceConfig, pubId, evaluator); + return { success: true }; } catch (error) { return { error: error.message }; @@ -85,7 +114,7 @@ export const decline = async (instanceId: string, pubId: string) => { evaluator = instanceState[user.id] = { ...evaluator, status: "declined" }; await setInstanceState(instanceId, pubId, instanceState); // Unschedule reminder email. - await unscheduleReminderEmail(instanceId, pubId, evaluator); + await unscheduleInvitationReminderEmail(instanceId, pubId, evaluator); // Unschedule no-reply notification email. await unscheduleNoReplyNotificationEmail(instanceId, pubId, evaluator); // Immediately send declined notification email. diff --git a/integrations/evaluations/app/actions/respond/respond.tsx b/integrations/evaluations/app/actions/respond/respond.tsx index cd5d3e0a0..efe0eb6a1 100644 --- a/integrations/evaluations/app/actions/respond/respond.tsx +++ b/integrations/evaluations/app/actions/respond/respond.tsx @@ -2,7 +2,8 @@ import { GetPubResponseBody } from "@pubpub/sdk"; import { useCallback } from "react"; -import { Button, toast } from "ui"; +import { Button } from "ui/button"; +import { toast } from "ui/use-toast"; import { accept, contact, decline } from "./actions"; import { InstanceConfig } from "~/lib/types"; import Link from "next/link"; diff --git a/integrations/evaluations/app/configure/configure.tsx b/integrations/evaluations/app/configure/configure.tsx index c9d442ba8..cfc5a39b8 100644 --- a/integrations/evaluations/app/configure/configure.tsx +++ b/integrations/evaluations/app/configure/configure.tsx @@ -2,14 +2,9 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { useEffect, useMemo } from "react"; import { useForm } from "react-hook-form"; +import { Button } from "ui/button"; +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "ui/card"; import { - Button, - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, Form, FormControl, FormDescription, @@ -17,16 +12,13 @@ import { FormItem, FormLabel, FormMessage, - Icon, - Input, - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, - Textarea, - useToast, -} from "ui"; +} from "ui/form"; +import { Loader2 } from "ui/icon"; +import { Input } from "ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "ui/select"; +import { Textarea } from "ui/textarea"; + +import { useToast } from "ui/use-toast"; import { cn } from "utils"; import * as z from "zod"; import { InstanceConfig } from "~/lib/types"; @@ -281,7 +273,7 @@ export function Configure(props: Props) { diff --git a/integrations/evaluations/app/layout.tsx b/integrations/evaluations/app/layout.tsx index 1a5b02836..5eadca0a4 100644 --- a/integrations/evaluations/app/layout.tsx +++ b/integrations/evaluations/app/layout.tsx @@ -1,5 +1,5 @@ import { User } from "@pubpub/sdk"; -import { Toaster } from "ui"; +import { Toaster } from "ui/toaster"; import "ui/styles.css"; import { expect } from "utils"; import { Integration } from "~/lib/Integration"; diff --git a/integrations/evaluations/lib/components/Research.tsx b/integrations/evaluations/lib/components/Research.tsx index 1a8724379..87a52f612 100644 --- a/integrations/evaluations/lib/components/Research.tsx +++ b/integrations/evaluations/lib/components/Research.tsx @@ -1,4 +1,4 @@ -import { Card, CardContent } from "ui"; +import { Card, CardContent } from "ui/card"; export type Props = { title: string; diff --git a/integrations/evaluations/lib/emails.ts b/integrations/evaluations/lib/emails.ts index ea3918e86..0c5da6e5d 100644 --- a/integrations/evaluations/lib/emails.ts +++ b/integrations/evaluations/lib/emails.ts @@ -29,6 +29,18 @@ export function calculateDeadline( } } +export function getDeadline(instanceConfig: InstanceConfig, evaluator: EvaluatorWhoAccepted): Date { + return evaluator.deadline + ? new Date(evaluator.deadline) + : calculateDeadline( + { + deadlineLength: instanceConfig.deadlineLength, + deadlineUnit: instanceConfig.deadlineUnit, + }, + new Date(evaluator.acceptedAt) + ); +} + const notificationFooter = '

This is an automated email sent from Unjournal. Please contact contact@unjournal.org with any questions.

'; @@ -41,6 +53,51 @@ const makeNoReplyJobKey = (instanceId: string, pubId: string, evaluator: Evaluat const makeNoSubmitJobKey = (instanceId: string, pubId: string, evaluator: EvaluatorWithInvite) => `send-email-${instanceId}-${pubId}-${evaluator.userId}-no-submit`; +const makePromptEvalBonusReminderJobKey = ( + instanceId: string, + pubId: string, + evaluator: EvaluatorWhoAccepted +) => `send-email-${instanceId}-${pubId}-${evaluator.userId}-prompt-eval-bonus-reminder`; + +const makeFinalPromptEvalBonusReminderJobKey = ( + instanceId: string, + pubId: string, + evaluator: EvaluatorWhoAccepted +) => `send-email-${instanceId}-${pubId}-${evaluator.userId}-final-prompt-eval-bonus-reminder`; + +const makeEvalReminderJobKey = ( + instanceId: string, + pubId: string, + evaluator: EvaluatorWhoAccepted +) => `send-email-${instanceId}-${pubId}-${evaluator.userId}-eval-reminder`; + +const makeFinalEvalReminderJobKey = ( + instanceId: string, + pubId: string, + evaluator: EvaluatorWhoAccepted +) => `send-email-${instanceId}-${pubId}-${evaluator.userId}-final-eval-reminder`; + +const makeFollowUpToFinalEvalReminderJobKey = ( + instanceId: string, + pubId: string, + evaluator: EvaluatorWhoAccepted +) => `send-email-${instanceId}-${pubId}-${evaluator.userId}-follow-up-to-final-eval-reminder`; + +const makeNoticeOfNoSubmitJobKey = ( + instanceId: string, + pubId: string, + evaluator: EvaluatorWhoAccepted +) => `send-email-${instanceId}-${pubId}-${evaluator.userId}-no-submit-notice`; + +// emails sent to the evaluation manager +/** + * Schedules an email to the evaluation manager to notify them that an invited evaluator has not responded to the invitation. + * @param instanceId + * @param instanceConfig + * @param pubId + * @param evaluator + * @returns + */ export const scheduleNoReplyNotificationEmail = async ( instanceId: string, instanceConfig: InstanceConfig, @@ -77,6 +134,13 @@ ${notificationFooter}`, ); }; +/** + * Unschedules the no reply notification email for the evaluation manager. + * @param instanceId + * @param pubId + * @param evaluator + * @returns + */ export const unscheduleNoReplyNotificationEmail = ( instanceId: string, pubId: string, @@ -86,6 +150,14 @@ export const unscheduleNoReplyNotificationEmail = ( return client.unscheduleEmail(instanceId, jobKey); }; +/** + * Schedules an email to the evaluation manager to notify them that an evaluator has not submitted their evaluation. + * @param instanceId + * @param instanceConfig + * @param pubId + * @param evaluator + * @returns + */ export const scheduleNoSubmitNotificationEmail = async ( instanceId: string, instanceConfig: InstanceConfig, @@ -93,15 +165,7 @@ export const scheduleNoSubmitNotificationEmail = async ( evaluator: EvaluatorWhoAccepted ) => { const jobKey = makeNoSubmitJobKey(instanceId, pubId, evaluator); - const deadline = evaluator.deadline - ? new Date(evaluator.deadline) - : calculateDeadline( - { - deadlineLength: instanceConfig.deadlineLength, - deadlineUnit: instanceConfig.deadlineUnit, - }, - new Date(evaluator.acceptedAt) - ); + const deadline = getDeadline(instanceConfig, evaluator); const runAt = deadline; await client.scheduleEmail( @@ -130,6 +194,13 @@ ${notificationFooter}`, ); }; +/** + * Unschedules the no submit notification email for the evaluation manager. + * @param instanceId + * @param pubId + * @param evaluator + * @returns + */ export const unscheduleNoSubmitNotificationEmail = ( instanceId: string, pubId: string, @@ -139,6 +210,153 @@ export const unscheduleNoSubmitNotificationEmail = ( return client.unscheduleEmail(instanceId, jobKey); }; +/** + * Sends an email to the evaluation manager to notify them that an evaluator has requested more information. + * @param instanceId + * @param instanceConfig + * @param pubId + * @param evaluator + * @returns + */ +export const sendRequestedInfoNotification = ( + instanceId: string, + instanceConfig: InstanceConfig, + pubId: string, + evaluator: EvaluatorWithInvite +) => { + return client.sendEmail(instanceId, { + to: { + userId: evaluator.invitedBy, + }, + subject: `[Unjournal] More Information Request for "{{pubs.submission.values["${instanceConfig.titleFieldSlug}"]}}"`, + message: `

An invited evaluator, {{users.evaluator.firstName}} {{users.evaluator.lastName}}, for "{{pubs.submission.values["${instanceConfig.titleFieldSlug}"]}}", has requested more information. You may contact them at {{users.evaluator.email}}.

+${notificationFooter}`, + include: { + pubs: { + submission: pubId, + }, + users: { + evaluator: evaluator.userId, + }, + }, + }); +}; + +/** + * Sends an email to the evaluation manager to notify them that an evaluator has accepted the invitation to evaluate the pub. + * @param instanceId + * @param instanceConfig + * @param pubId + * @param evaluator + * @returns + */ +export const sendAcceptedNotificationEmail = ( + instanceId: string, + instanceConfig: InstanceConfig, + pubId: string, + evaluator: EvaluatorWithInvite +) => { + return client.sendEmail(instanceId, { + to: { + userId: evaluator.invitedBy, + }, + subject: `[Unjournal] Accepted evaluation for "{{pubs.submission.values["${instanceConfig.titleFieldSlug}"]}}"`, + message: `

An invited evaluator, {{users.evaluator.firstName}} {{users.evaluator.lastName}}, has agreed to evaluate "{{pubs.submission.values["${instanceConfig.titleFieldSlug}"]}}". You may review the status of this and other invitations on the {{extra.manage_link}}.

+${notificationFooter}`, + include: { + pubs: { + submission: pubId, + }, + users: { + evaluator: evaluator.userId, + }, + }, + extra: { + manage_link: `Invite Evaluators page`, + }, + }); +}; + +/** + * Sends an email to the evaluation manager to notify them that an evaluator has declined the invitation to evaluate the pub. + * @param instanceId + * @param instanceConfig + * @param pubId + * @param evaluator + * @returns + */ +export const sendDeclinedNotificationEmail = async ( + instanceId: string, + instanceConfig: InstanceConfig, + pubId: string, + evaluator: EvaluatorWithInvite +) => { + return client.sendEmail(instanceId, { + to: { + userId: evaluator.invitedBy, + }, + subject: `[Unjournal] Invited evaluator declines to evaluate "{{pubs.submission.values["${instanceConfig.titleFieldSlug}"]}}"`, + message: `

An invited evaluator, {{users.evaluator.firstName}} {{users.evaluator.lastName}}, has declined to evaluate "{{pubs.submission.values["${instanceConfig.titleFieldSlug}"]}}". You may review the status of this and other invitations on the {{extra.manage_link}}.

+${notificationFooter}`, + include: { + pubs: { + submission: pubId, + }, + users: { + evaluator: evaluator.userId, + }, + }, + extra: { + manage_link: `Invite Evaluators page`, + }, + }); +}; + +/** + * Sends an email to the evaluation manager to notify them that an evaluator has submitted their evaluation. + * @param instanceId + * @param instanceConfig + * @param pubId + * @param evaluator + * @returns + */ +export const sendSubmittedNotificationEmail = async ( + instanceId: string, + instanceConfig: InstanceConfig, + pubId: string, + evaluator: EvaluatorWhoEvaluated +) => { + return client.sendEmail(instanceId, { + to: { + userId: evaluator.invitedBy, + }, + subject: `[Unjournal] Evaluation submitted for "{{pubs.submission.values["${instanceConfig.titleFieldSlug}"]}}"`, + message: `

An evaluator, {{users.evaluator.firstName}} {{users.evaluator.lastName}}, has submitted an evaluation for "{{pubs.submission.values["${instanceConfig.titleFieldSlug}"]}}". The submitted evaluation Pub can be viewed here.

+

You may review the status of this and other invitations on the {{extra.manage_link}}.

+${notificationFooter}`, + include: { + pubs: { + submission: pubId, + }, + users: { + evaluator: evaluator.userId, + }, + }, + extra: { + manage_link: `Invite Evaluators page`, + }, + }); +}; + +// emails sent to the evaluator +/** + * + * Sends an email to the evaluator with the invitation to evaluate the pub. + * @param instanceId + * @param pubId + * @param evaluator + * @returns Promise that resolves to the result of the email send operation. + */ export const sendInviteEmail = async ( instanceId: string, pubId: string, @@ -166,7 +384,14 @@ export const sendInviteEmail = async ( }); }; -export const scheduleReminderEmail = async ( +/** + * Schedules an email to the evaluator as a reminder to accept the invitation to evaluate the pub. + * @param instanceId + * @param instanceConfig + * @param pubId + * @param evaluator + */ +export const scheduleInvitationReminderEmail = async ( instanceId: string, instanceConfig: InstanceConfig, pubId: string, @@ -202,7 +427,14 @@ export const scheduleReminderEmail = async ( ); }; -export const unscheduleReminderEmail = ( +/** + * Cancels the scheduled reminder email for the evaluator to accept the invitation to evaluate the pub. + * @param instanceId + * @param pubId + * @param evaluator + * @returns + */ +export const unscheduleInvitationReminderEmail = ( instanceId: string, pubId: string, evaluator: EvaluatorWithInvite @@ -211,21 +443,21 @@ export const unscheduleReminderEmail = ( return client.unscheduleEmail(instanceId, jobKey); }; +/** + * Sends an email to the evaluator to inform them that their invitation to evaluate the pub has been accepted. + * @param instanceId + * @param instanceConfig + * @param pubId + * @param evaluator + * @returns + */ export const sendAcceptedEmail = async ( instanceId: string, instanceConfig: InstanceConfig, pubId: string, evaluator: EvaluatorWhoAccepted ) => { - const deadline = evaluator.deadline - ? new Date(evaluator.deadline) - : calculateDeadline( - { - deadlineLength: instanceConfig.deadlineLength, - deadlineUnit: instanceConfig.deadlineUnit, - }, - new Date(evaluator.acceptedAt) - ); + const deadline = getDeadline(instanceConfig, evaluator); await client.sendEmail(instanceId, { to: { userId: evaluator.userId, @@ -261,108 +493,361 @@ export const sendAcceptedEmail = async ( }); }; -export const sendRequestedInfoNotification = ( +/** + * Schedules a reminder email to an evaluator for prompt evaluation bonus. + * @param instanceId - The ID of the instance. + * @param instanceConfig - The configuration of the instance. + * @param pubId - The ID of the publication. + * @param evaluator - The evaluator who accepted the evaluation. + * @returns A promise that resolves when the email is sent. + */ +export const schedulePromptEvalBonusReminderEmail = async ( instanceId: string, instanceConfig: InstanceConfig, pubId: string, - evaluator: EvaluatorWithInvite + evaluator: EvaluatorWhoAccepted ) => { - return client.sendEmail(instanceId, { - to: { - userId: evaluator.invitedBy, - }, - subject: `[Unjournal] More Information Request for "{{pubs.submission.values["${instanceConfig.titleFieldSlug}"]}}"`, - message: `

An invited evaluator, {{users.evaluator.firstName}} {{users.evaluator.lastName}}, for "{{pubs.submission.values["${instanceConfig.titleFieldSlug}"]}}", has requested more information. You may contact them at {{users.evaluator.email}}.

-${notificationFooter}`, - include: { - pubs: { - submission: pubId, + const deadline = getDeadline(instanceConfig, evaluator); + const reminderDeadline = new Date(deadline.getTime() - 21 * (1000 * 60 * 60 * 24)); + const jobKey = makePromptEvalBonusReminderJobKey(instanceId, pubId, evaluator); + const runAt = reminderDeadline; + + return client.scheduleEmail( + instanceId, + { + to: { + userId: evaluator.userId, }, - users: { - evaluator: evaluator.userId, + subject: `[Unjournal] Reminder to evaluate "{{pubs.submission.values["${instanceConfig.titleFieldSlug}"]}}" for prompt evaluation bonus`, + message: `

Hi {{user.firstName}},

+

Thanks again for agreeing to evaluate "{{pubs.submission.values["${ + instanceConfig.titleFieldSlug + }"]}}" for The Unjournal.

+

This note is a reminder to submit your evaluation by ${reminderDeadline.toLocaleDateString()} to receive a $100 “prompt evaluation bonus,” in addition to your baseline compensation. Please note that after ${new Date( + deadline + ).toLocaleDateString()} we will consider re-assigning the evaluation, and later submissions may not be eligible for the full baseline compensation.

+

Please submit your evaluation and rating, as well as any specific considerations, using this evaluation form. The form includes instructions and information about the paper/project.

+

If you have any questions, do not hesitate to reach out to me at {{users.invitor.email}}.

+

Once your evaluation has been submitted and reviewed, we will follow up with details about payment and next steps.

+

Thanks and best wishes,

+

{{users.invitor.firstName}} {{users.invitor.lastName}}

+

Unjournal.org

`, + include: { + pubs: { + submission: pubId, + }, + users: { + invitor: evaluator.invitedBy, + }, + }, + extra: { + evaluate_link: `{{instance.actions.evaluate}}?instanceId={{instance.id}}&pubId={{pubs.submission.id}}&token={{user.token}}`, }, }, - }); + { jobKey, runAt } + ); }; -export const sendAcceptedNotificationEmail = ( +/** + * Schedules a final reminder email to an evaluator for prompt evaluation bonus. + * @param instanceId + * @param instanceConfig + * @param pubId + * @param evaluator + * @returns + */ +export const scheduleFinalPromptEvalBonusReminderEmail = async ( instanceId: string, instanceConfig: InstanceConfig, pubId: string, - evaluator: EvaluatorWithInvite + evaluator: EvaluatorWhoAccepted ) => { - return client.sendEmail(instanceId, { - to: { - userId: evaluator.invitedBy, - }, - subject: `[Unjournal] Accepted evaluation for "{{pubs.submission.values["${instanceConfig.titleFieldSlug}"]}}"`, - message: `

An invited evaluator, {{users.evaluator.firstName}} {{users.evaluator.lastName}}, has agreed to evaluate "{{pubs.submission.values["${instanceConfig.titleFieldSlug}"]}}". You may review the status of this and other invitations on the {{extra.manage_link}}.

-${notificationFooter}`, - include: { - pubs: { - submission: pubId, + const deadline = getDeadline(instanceConfig, evaluator); + const reminderDeadline = new Date(deadline.getTime() - 14 * (1000 * 60 * 60 * 24)); + const jobKey = makeFinalPromptEvalBonusReminderJobKey(instanceId, pubId, evaluator); + const runAt = reminderDeadline; + + return client.scheduleEmail( + instanceId, + { + to: { + userId: evaluator.userId, }, - users: { - evaluator: evaluator.userId, + subject: `[Unjournal] Final Reminder: Submit evaluation for prompt bonus "{{pubs.submission.values["${instanceConfig.titleFieldSlug}"]}}"`, + message: `

Hi {{user.firstName}},

+

This is a final reminder to submit your evaluation for "{{pubs.submission.values["${ + instanceConfig.titleFieldSlug + }"]}}" by the deadline ${reminderDeadline.toLocaleDateString()} to receive the $100 “prompt evaluation bonus.”

+

If you haven't already, please submit your evaluation and rating, as well as any specific considerations, using this evaluation form. The form includes instructions and information about the paper/project.

+

If you have any questions, do not hesitate to reach out to me at {{users.invitor.email}}.

+

Once your evaluation has been submitted and reviewed, we will follow up with details about payment and next steps.

+

Thanks and best wishes,

+

{{users.invitor.firstName}} {{users.invitor.lastName}}

+

Unjournal.org

`, + include: { + pubs: { + submission: pubId, + }, + users: { + invitor: evaluator.invitedBy, + }, + }, + extra: { + evaluate_link: `{{instance.actions.evaluate}}?instanceId={{instance.id}}&pubId={{pubs.submission.id}}&token={{user.token}}`, }, }, - extra: { - manage_link: `Invite Evaluators page`, - }, - }); + { jobKey, runAt } + ); }; -export const sendDeclinedNotificationEmail = async ( +/** + * Schedules a reminder email to an evaluator to submit their evaluation. + * @param instanceId + * @param instanceConfig + * @param pubId + * @param evaluator + * @returns + */ +export const scheduleEvaluationReminderEmail = async ( instanceId: string, instanceConfig: InstanceConfig, pubId: string, - evaluator: EvaluatorWithInvite + evaluator: EvaluatorWhoAccepted ) => { - return client.sendEmail(instanceId, { - to: { - userId: evaluator.invitedBy, - }, - subject: `[Unjournal] Invited evaluator declines to evaluate "{{pubs.submission.values["${instanceConfig.titleFieldSlug}"]}}"`, - message: `

An invited evaluator, {{users.evaluator.firstName}} {{users.evaluator.lastName}}, has declined to evaluate "{{pubs.submission.values["${instanceConfig.titleFieldSlug}"]}}". You may review the status of this and other invitations on the {{extra.manage_link}}.

-${notificationFooter}`, - include: { - pubs: { - submission: pubId, + const deadline = getDeadline(instanceConfig, evaluator); + const jobKey = makeEvalReminderJobKey(instanceId, pubId, evaluator); + const runAt = new Date(deadline.getTime() - 7 * (1000 * 60 * 60 * 24)); + + return client.scheduleEmail( + instanceId, + { + to: { + userId: evaluator.userId, }, - users: { - evaluator: evaluator.userId, + subject: `[Unjournal] Reminder to evaluate "{{pubs.submission.values["${instanceConfig.titleFieldSlug}"]}}" by next week`, + message: `

Hi {{user.firstName}},

+

Thank you again for agreeing to evaluate "{{pubs.submission.values["${ + instanceConfig.titleFieldSlug + }"]}}" for The Unjournal.

+

This note is a reminder that your evaluation should be submitted by ${new Date( + deadline.getTime() + ).toLocaleDateString()} (next week). Please note that after that date we will consider re-assigning the evaluation, and later submissions may not be eligible for the full baseline compensation.

+

Please submit your evaluation and rating, as well as any specific considerations, using this evaluation form. The form includes instructions and information about the paper/project.

+

If you have any questions, do not hesitate to reach out to me at {{users.invitor.email}}.

+

Once your evaluation has been submitted and reviewed, we will follow up with details about payment and next steps.

+

Thanks and best wishes,

+

{{users.invitor.firstName}} {{users.invitor.lastName}}

+

Unjournal.org

`, + include: { + pubs: { + submission: pubId, + }, + users: { + invitor: evaluator.invitedBy, + }, + }, + extra: { + evaluate_link: `{{instance.actions.evaluate}}?instanceId={{instance.id}}&pubId={{pubs.submission.id}}&token={{user.token}}`, }, }, - extra: { - manage_link: `Invite Evaluators page`, - }, - }); + { jobKey, runAt } + ); }; -export const sendSubmittedNotificationEmail = async ( +/** + * Schedules a final reminder email to an evaluator to submit their evaluation. + * @param instanceId + * @param instanceConfig + * @param pubId + * @param evaluator + * @returns + */ +export const scheduleFinalEvaluationReminderEmail = async ( instanceId: string, instanceConfig: InstanceConfig, pubId: string, - evaluator: EvaluatorWhoEvaluated + evaluator: EvaluatorWhoAccepted ) => { - return client.sendEmail(instanceId, { - to: { - userId: evaluator.invitedBy, + const deadline = getDeadline(instanceConfig, evaluator); + const jobKey = makeFinalEvalReminderJobKey(instanceId, pubId, evaluator); + const runAt = new Date(deadline.getTime() - 1 * (1000 * 60 * 60 * 24)); + + return client.scheduleEmail( + instanceId, + { + to: { + userId: evaluator.userId, + }, + subject: `[Unjournal] Final Reminder: Evaluation due tomorrow`, + message: `

Hi {{user.firstName}},

+

This note is a final reminder that your evaluation for "{{pubs.submission.values["${instanceConfig.titleFieldSlug}"]}}" is due tomorrow. Please make sure to submit your evaluation by the deadline.

+

If you haven't already, please submit your evaluation and rating, as well as any specific considerations, using this evaluation form. The form includes instructions and information about the paper/project.

+

If you have any questions, do not hesitate to reach out to me at {{users.invitor.email}}.

+

Once your evaluation has been submitted and reviewed, we will follow up with details about payment and next steps.

+

Thanks and best wishes,

+

{{users.invitor.firstName}} {{users.invitor.lastName}}

+

Unjournal.org

`, + include: { + pubs: { + submission: pubId, + }, + users: { + invitor: evaluator.invitedBy, + }, + }, + extra: { + evaluate_link: `{{instance.actions.evaluate}}?instanceId={{instance.id}}&pubId={{pubs.submission.id}}&token={{user.token}}`, + }, }, - subject: `[Unjournal] Evaluation submitted for "{{pubs.submission.values["${instanceConfig.titleFieldSlug}"]}}"`, - message: `

An evaluator, {{users.evaluator.firstName}} {{users.evaluator.lastName}}, has submitted an evaluation for "{{pubs.submission.values["${instanceConfig.titleFieldSlug}"]}}". The submitted evaluation Pub can be viewed here.

-

You may review the status of this and other invitations on the {{extra.manage_link}}.

-${notificationFooter}`, - include: { - pubs: { - submission: pubId, + { jobKey, runAt } + ); +}; + +/** + * Schedules a follow-up to evaluation reminder email to an evaluator. + * @param instanceId + * @param instanceConfig + * @param pubId + * @param evaluator + * @returns + */ +export const scheduleFollowUpToFinalEvaluationReminderEmail = async ( + instanceId: string, + instanceConfig: InstanceConfig, + pubId: string, + evaluator: EvaluatorWhoAccepted +) => { + const deadline = getDeadline(instanceConfig, evaluator); + const jobKey = makeFollowUpToFinalEvalReminderJobKey(instanceId, pubId, evaluator); + const runAt = new Date(deadline.getTime() + 6 * (1000 * 60 * 60 * 24)); + + return client.scheduleEmail( + instanceId, + { + to: { + userId: evaluator.userId, }, - users: { - evaluator: evaluator.userId, + subject: `[Unjournal] Follow-up: Evaluation overdue, to be reassigned`, + message: `

Hi {{user.firstName}},

+

This note is a reminder that your evaluation for "{{pubs.submission.values["${ + instanceConfig.titleFieldSlug + }"]}}" is overdue. We are now planning to reassign the evaluation to another evaluator.

+

If you have completed the evaluation but forgot to submit it, please submit your evaluation and rating today using this evaluation form. If we don't hear from you by the end of ${new Date( + deadline.getTime() + 7 * (1000 * 60 * 60 * 24) + ).toLocaleDateString()}, we will remove you from this assignment and you will no longer be eligible for compensation.

+

If you have any questions, do not hesitate to reach out to me at {{users.invitor.email}}.

+

Thanks and best wishes,

+

{{users.invitor.firstName}} {{users.invitor.lastName}}

+

Unjournal.org

`, + include: { + pubs: { + submission: pubId, + }, + users: { + invitor: evaluator.invitedBy, + }, + }, + extra: { + evaluate_link: `{{instance.actions.evaluate}}?instanceId={{instance.id}}&pubId={{pubs.submission.id}}&token={{user.token}}`, }, }, - extra: { - manage_link: `Invite Evaluators page`, + { jobKey, runAt } + ); +}; + +/** + * Schedules a notice of no submit email to an evaluator. + * @param instanceId + * @param instanceConfig + * @param pubId + * @param evaluator + * @returns + */ +export const sendNoticeOfNoSubmitEmail = async ( + instanceId: string, + instanceConfig: InstanceConfig, + pubId: string, + evaluator: EvaluatorWhoAccepted +) => { + const deadline = getDeadline(instanceConfig, evaluator); + const jobKey = makeNoticeOfNoSubmitJobKey(instanceId, pubId, evaluator); + const runAt = new Date(deadline.getTime() + 8 * (1000 * 60 * 60 * 24)); + + return client.scheduleEmail( + instanceId, + { + to: { + userId: evaluator.userId, + }, + subject: `[Unjournal] Evaluation not submitted for "{{pubs.submission.values["${instanceConfig.titleFieldSlug}"]}}"`, + message: `

Hi {{user.firstName}},

+

This is to inform you that you have not submitted an evaluation for "{{pubs.submission.values["${ + instanceConfig.titleFieldSlug + }"]}}", which was due on ${new Date(deadline.getTime()).toLocaleDateString()}.

+

If you have completed the evaluation but forgot to submit it, please submit your evaluation and rating today using this evaluation form. If we don't hear from you by the end of ${new Date( + deadline.getTime() + ).toLocaleDateString()}, we will remove you from this assignment and you will no longer be eligible for compensation.

+

If you have any questions, do not hesitate to reach out to me at {{users.invitor.email}}.

+

Thanks and best wishes,

+

{{users.invitor.firstName}} {{users.invitor.lastName}}

+

Unjournal.org

`, + include: { + pubs: { + submission: pubId, + }, + users: { + invitor: evaluator.invitedBy, + }, + }, + extra: { + evaluate_link: `{{instance.actions.evaluate}}?instanceId={{instance.id}}&pubId={{pubs.submission.id}}&token={{user.token}}`, + }, }, - }); + { jobKey, runAt } + ); +}; + +/** + * Unschedules all the deadline reminder emails. + * `client.unscheduleEmail()` returns no-op for emails that have been + * sent, allowing us to call it without checking if an error + * @param instanceId + * @param pubId + * @param evaluator + * @returns + */ +export const unscheduleAllDeadlineReminderEmails = async ( + instanceId: string, + pubId: string, + evaluator: EvaluatorWhoAccepted +) => { + const jobKeys = [ + makePromptEvalBonusReminderJobKey(instanceId, pubId, evaluator), + makeFinalPromptEvalBonusReminderJobKey(instanceId, pubId, evaluator), + makeEvalReminderJobKey(instanceId, pubId, evaluator), + makeFinalEvalReminderJobKey(instanceId, pubId, evaluator), + makeFollowUpToFinalEvalReminderJobKey(instanceId, pubId, evaluator), + makeNoticeOfNoSubmitJobKey(instanceId, pubId, evaluator), + ]; + return Promise.all(jobKeys.map((jobKey) => client.unscheduleEmail(instanceId, jobKey))); +}; + +/** + * Unschedules all emails. + * @param instanceId + * @param pubId + * @param evaluator + * @returns + */ +export const unscheduleAllManagerEmails = async ( + instanceId: string, + pubId: string, + evaluator: EvaluatorWithInvite +) => { + const jobKeys = [ + makeReminderJobKey(instanceId, pubId, evaluator), + makeNoReplyJobKey(instanceId, pubId, evaluator), + makeNoSubmitJobKey(instanceId, pubId, evaluator), + ]; + return Promise.all(jobKeys.map((jobKey) => client.unscheduleEmail(instanceId, jobKey))); }; diff --git a/integrations/submissions/app/actions/submit/FetchMetadataButton.tsx b/integrations/submissions/app/actions/submit/FetchMetadataButton.tsx index 828428d49..d7493014b 100644 --- a/integrations/submissions/app/actions/submit/FetchMetadataButton.tsx +++ b/integrations/submissions/app/actions/submit/FetchMetadataButton.tsx @@ -2,16 +2,11 @@ import { useTransition } from "react"; import { useFormContext } from "react-hook-form"; -import { - Button, - Icon, - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, - useFormField, - useToast, -} from "ui"; +import { Button } from "ui/button"; +import { Loader2, Wand2 } from "ui/icon"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "ui/tooltip"; +import { useFormField } from "ui/form"; +import { useToast } from "ui/use-toast"; import { cn } from "utils"; import { resolveMetadata } from "./actions"; @@ -79,9 +74,9 @@ export const FetchMetadataButton = (props: FetchMetadataButtonProps) => { disabled={!state.isDirty || state.invalid} > {pending ? ( - + ) : ( - + )} diff --git a/integrations/submissions/app/actions/submit/submit.tsx b/integrations/submissions/app/actions/submit/submit.tsx index 437104632..2486af026 100644 --- a/integrations/submissions/app/actions/submit/submit.tsx +++ b/integrations/submissions/app/actions/submit/submit.tsx @@ -3,14 +3,9 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { useEffect } from "react"; import { useForm } from "react-hook-form"; +import { Button } from "ui/button"; +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "ui/card"; import { - Button, - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, Form, FormControl, FormDescription, @@ -18,12 +13,12 @@ import { FormItem, FormLabel, FormMessage, - Icon, - Input, - Textarea, - useLocalStorage, - useToast, -} from "ui"; +} from "ui/form"; +import { Loader2 } from "ui/icon"; +import { Input } from "ui/input"; +import { Textarea } from "ui/textarea"; +import { useLocalStorage } from "ui/hooks"; +import { useToast } from "ui/use-toast"; import { DOI_REGEX, URL_REGEX, cn, isDoi, normalizeDoi } from "utils"; import * as z from "zod"; import { FetchMetadataButton } from "./FetchMetadataButton"; @@ -230,7 +225,7 @@ export function Submit(props: Props) { diff --git a/integrations/submissions/app/configure/configure.tsx b/integrations/submissions/app/configure/configure.tsx index 102a5e587..cb549ffc3 100644 --- a/integrations/submissions/app/configure/configure.tsx +++ b/integrations/submissions/app/configure/configure.tsx @@ -2,8 +2,8 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { useForm } from "react-hook-form"; +import { Button } from "ui/button"; import { - Button, Form, FormControl, FormDescription, @@ -11,16 +11,12 @@ import { FormItem, FormLabel, FormMessage, - Icon, - Input, - useToast, - Card, - CardHeader, - CardFooter, - CardContent, - CardTitle, - CardDescription, -} from "ui"; +} from "ui/form"; +import { Loader2 } from "ui/icon"; +import { Input } from "ui/input"; +import { useToast } from "ui/use-toast"; +import { Card, CardHeader, CardFooter, CardContent, CardTitle, CardDescription } from "ui/card"; + import { cn } from "utils"; import * as z from "zod"; import { configure } from "./actions"; @@ -104,7 +100,7 @@ export function Configure(props: Props) { diff --git a/integrations/submissions/app/layout.tsx b/integrations/submissions/app/layout.tsx index 1a70b5a9d..25dce1620 100644 --- a/integrations/submissions/app/layout.tsx +++ b/integrations/submissions/app/layout.tsx @@ -1,7 +1,7 @@ import { User } from "@pubpub/sdk"; import { cookies, headers } from "next/headers"; -import { Toaster } from "ui"; import { env } from "~/lib/env.mjs"; +import { Toaster } from "ui/toaster"; import "ui/styles.css"; import { expect } from "utils"; import { Integration } from "~/lib/Integration"; diff --git a/package.json b/package.json index 4900379b6..59701ebab 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "@babel/preset-react": "^7.22.15", "@babel/preset-typescript": "^7.22.15", "@changesets/cli": "^2.26.2", - "@preconstruct/cli": "^2.8.1", + "@preconstruct/cli": "^2.8.3", "husky": "^8.0.3", "lint-staged": "^13.2.2", "prettier": "^2.7.1", diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index 622ac58e1..4049295f2 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -9,7 +9,6 @@ import { ScheduleEmailResponseBody, SendEmailRequestBody, SendEmailResponseBody, - UpdatePubRequestBody, UpdatePubResponseBody, User, api, diff --git a/packages/sdk/src/react/Integration.tsx b/packages/sdk/src/react/Integration.tsx index 7679df75c..f36210eb7 100644 --- a/packages/sdk/src/react/Integration.tsx +++ b/packages/sdk/src/react/Integration.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { LocalStorageProvider } from "ui"; +import { LocalStorageProvider } from "ui/hooks"; import { IntegrationLayout } from "./IntegrationLayout"; import { IntegrationProvider, IntegrationProviderProps } from "./IntegrationProvider"; diff --git a/packages/sdk/src/react/IntegrationAvatar.tsx b/packages/sdk/src/react/IntegrationAvatar.tsx index 659d01c99..046b6ba55 100644 --- a/packages/sdk/src/react/IntegrationAvatar.tsx +++ b/packages/sdk/src/react/IntegrationAvatar.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { Avatar, AvatarImage, AvatarFallback } from "ui"; +import { Avatar, AvatarImage, AvatarFallback } from "ui/avatar"; type Props = { firstName: string; diff --git a/packages/sdk/src/react/generateFormFields.tsx b/packages/sdk/src/react/generateFormFields.tsx index 0c4a5fb75..faf117eb5 100644 --- a/packages/sdk/src/react/generateFormFields.tsx +++ b/packages/sdk/src/react/generateFormFields.tsx @@ -3,20 +3,13 @@ import * as React from "react"; import Ajv, { JSONSchemaType } from "ajv"; import { GetPubTypeResponseBody } from "contracts"; import { Control, ControllerRenderProps } from "react-hook-form"; -import { - Checkbox, - Confidence, - FileUpload, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, - Input, - Separator, - Textarea, -} from "ui"; +import { Checkbox } from "ui/checkbox"; +import { Confidence } from "ui/customRenderers/confidence/confidence"; +import { FileUpload } from "ui/customRenderers/fileUpload/fileUpload"; +import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "ui/form"; +import { Input } from "ui/input"; +import { Separator } from "ui/separator"; +import { Textarea } from "ui/textarea"; import { cn } from "utils"; // a bit of a hack, but allows us to use AJV's JSON schema type diff --git a/packages/ui/README.md b/packages/ui/README.md index 396595032..7bbc155b5 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -1,27 +1,46 @@ -# PubPub UI +# PubPub UI + +## How to import + +You need to import the component from the `ui/\` path. For example: + +```tsx +import { Button } from "ui/button"; +import { Loader2 } from "ui/icon"; +``` + +## How to add a new component + +1. Create a file in src, something like `src/component.tsx`, that exports a _named_ Component. Ideally copy-paste something from https://ui.shadcn.com/components +2. Add that file to `package.json['entryPoints']` as `component.tsx` +3. Add component to the `package.json['files']` array +4. In packages/ui, run `pnpm preconstruct fix && pnpm preconstruct dev` +5. Everything should be good! ## Rationale for our style approach + In the past, PubPub has stuck pretty close to the style system offered by the off-the-shelf component library we've used (primarily BlueprintJS). This is convenient because it allowed us to move quickly early on, but has become a liability over time as our design needs to deviate from the Blueprint design, and as the Blueprint library introduces breaking changes. In v7, we find ourselves with an additional styling objective, which is to allow integrations (both 1st and 3rd party) to easily adopt the styles and design of `core`. Further, we hope to do this without requiring every integration to use the exact same technical stack as `core`. Other important considerations include site-wide theming capabilities, robust Figma offerings, and performance (this was a noticeable issue with Blueprint as PubPub v6 scaled). -Other minor considerations include a preference to avoid CSS-in-JS approaches, size of the open source community, sustainability of the library (having a library stop maintenance because it had no business model puts us in a tough position), component-level bundling (*all* of BlueprintJS ships with every page even if we only used a single component), and friendliness with server-side rendering (i.e. no FOUC while it waits for client-side JS to load). +Other minor considerations include a preference to avoid CSS-in-JS approaches, size of the open source community, sustainability of the library (having a library stop maintenance because it had no business model puts us in a tough position), component-level bundling (_all_ of BlueprintJS ships with every page even if we only used a single component), and friendliness with server-side rendering (i.e. no FOUC while it waits for client-side JS to load). In experimenting with different component libraries and workflows, a consistent fork in the road kept appearing. We can either: + 1. pick a React library and require integrations to use React if they want to be styled similarly, or 2. we need to pick an approach that works in vanilla HTML -The first approach would let us choose something like Chakra, Mantine, or Blueprint. The second approach leaves us essentially choosing between writing all CSS classes ourselves or picking a tool like Tailwind. +The first approach would let us choose something like Chakra, Mantine, or Blueprint. The second approach leaves us essentially choosing between writing all CSS classes ourselves or picking a tool like Tailwind. -I feel strongly that integrations shouldn't *have* to use the same stack as us (i.e. React), so spent time focusing on approach (2). Down that path, Tailwind offers a lot that writing our own classes from scratch does not. Writing raw CSS everywhere feels too difficult to maintain for a large project and I think we’d frankly wind up essentially re-implementing tailwind, but with less familiarity for integration developers and worse documentation. Further, +I feel strongly that integrations shouldn't _have_ to use the same stack as us (i.e. React), so spent time focusing on approach (2). Down that path, Tailwind offers a lot that writing our own classes from scratch does not. Writing raw CSS everywhere feels too difficult to maintain for a large project and I think we’d frankly wind up essentially re-implementing tailwind, but with less familiarity for integration developers and worse documentation. Further, -- It’s got great documentation with best practices and a huge community -- It has tons of community Figma boards -- It lets us offer a simple configuration plugin (i.e. `plugins: [require("pubpub/styles")]`) that defines colors, borders, fonts, etc that could allow integrations to easily get good-enough similarity with little other work. +- It’s got great documentation with best practices and a huge community +- It has tons of community Figma boards +- It lets us offer a simple configuration plugin (i.e. `plugins: [require("pubpub/styles")]`) that defines colors, borders, fonts, etc that could allow integrations to easily get good-enough similarity with little other work. -So, if we run with the assumption that we'll use Tailwind and integrations have to drop in Tailwind to achieve similar styling, the next question is: Do we build components ourselves or take off the shelf ones? The question feels trivial: we shouldn't try to rebuild complex UI components from scratch. Accessibility is hard. Cross-browser support for edge case interactions is hard. +So, if we run with the assumption that we'll use Tailwind and integrations have to drop in Tailwind to achieve similar styling, the next question is: Do we build components ourselves or take off the shelf ones? The question feels trivial: we shouldn't try to rebuild complex UI components from scratch. Accessibility is hard. Cross-browser support for edge case interactions is hard. I spent some time playing with Flowbite, which offers an extensive component library built from native HTML and tailwind css. Flowbite even offers a 1st-party React library of their components. Unfortunately, Flowbite-React seems insufficient for us. It still has a good deal of bugs, does not have complete component coverage, and their experimental theming is doing a JS theming thing instead of just using tailwind config (! So it winds up essentially being the same as Chakra or Mantine, in that the component library bundles its styles with it). @@ -36,33 +55,37 @@ Because integrations that are written with a different framework won't be able t This also gives us a lot of flexibility in building components. We can mix and match headless libraries if we want — there's no problem having, for example, both Radix and AriaKit since they aren’t fighting for style. We're not forced into a monolithic decision. Of course, headless component libraries have a lot of overlap, so I expect we would mostly use the same one, but from the perspective integration developers and performance, there's really no "commitment" beyond tailwind. On top of Tailwind, there is a lot of good community work to draw from: -- Radix and shadcn/ui -- AriaKit components -- HeadlessUI and open source TailwindUI components built on top of it -- Countless Tailwind component libraries -- Countless Tailwind Figma boards + +- Radix and shadcn/ui +- AriaKit components +- HeadlessUI and open source TailwindUI components built on top of it +- Countless Tailwind component libraries +- Countless Tailwind Figma boards The other perk of this approach is that all component styling is kept in pubpub-core, as opposed to deep in some node module. This makes it a bit easier for someone who's trying to port a component into Vue or Svelte and want it to look like PubPub components — the core HTML and tailwind classes are easily found. In the end, this approach resonates with me because: -- It feels as close to vanilla CSS as we can get without forcing ourselves to write all our classes and components from scratch. -- It's performant since it's just CSS at the end of the day (especially compared to monolithic component libraries like Blueprint). -- It offers integration developers a simple approach to add our tailwind config as a plugin. -- It allows our components to use headless UI libraries focused on accessibility. -- We maintain complete control of top-level components and styling. + +- It feels as close to vanilla CSS as we can get without forcing ourselves to write all our classes and components from scratch. +- It's performant since it's just CSS at the end of the day (especially compared to monolithic component libraries like Blueprint). +- It offers integration developers a simple approach to add our tailwind config as a plugin. +- It allows our components to use headless UI libraries focused on accessibility. +- We maintain complete control of top-level components and styling. The primary tradeoff to all of this is a little more up front work compared to using something like Chakra, but there is so much community offering that I don't feel concerned about our ability to build quickly. ## Links and Resources + A list of resources I found helpful in my spike: -- [CSS Solution Analysis for Polaris Foundations](https://docs.google.com/spreadsheets/d/1rxrRTlbNWiLVu-Q5IK7xh5O1FmWcjyAS2XN7jiPrhYM/edit#gid=0) - - An in depth analysis of different CSS approaches. A few years old, and notably, current Tailwind has addressed nearly all its issues (multiple themes and build-time performance). -- [Tailwind](https://tailwindcss.com/) -- [shadcn/ui](https://ui.shadcn.com/) -- [Radix](https://www.radix-ui.com/) -- [Chakra](https://chakra-ui.com/) -- [Mantine](https://mantine.dev/) -- [HeadlessUI](https://headlessui.com/) -- [AriaKit](https://ariakit.org/) -- [Flowbite](https://flowbite.com/) -- [Flowbite-React](https://www.flowbite-react.com/) + +- [CSS Solution Analysis for Polaris Foundations](https://docs.google.com/spreadsheets/d/1rxrRTlbNWiLVu-Q5IK7xh5O1FmWcjyAS2XN7jiPrhYM/edit#gid=0) + - An in depth analysis of different CSS approaches. A few years old, and notably, current Tailwind has addressed nearly all its issues (multiple themes and build-time performance). +- [Tailwind](https://tailwindcss.com/) +- [shadcn/ui](https://ui.shadcn.com/) +- [Radix](https://www.radix-ui.com/) +- [Chakra](https://chakra-ui.com/) +- [Mantine](https://mantine.dev/) +- [HeadlessUI](https://headlessui.com/) +- [AriaKit](https://ariakit.org/) +- [Flowbite](https://flowbite.com/) +- [Flowbite-React](https://www.flowbite-react.com/) diff --git a/packages/ui/alert/package.json b/packages/ui/alert/package.json new file mode 100644 index 000000000..07d4e222d --- /dev/null +++ b/packages/ui/alert/package.json @@ -0,0 +1,4 @@ +{ + "main": "dist/ui-alert.cjs.js", + "module": "dist/ui-alert.esm.js" +} diff --git a/packages/ui/avatar/package.json b/packages/ui/avatar/package.json new file mode 100644 index 000000000..beb541505 --- /dev/null +++ b/packages/ui/avatar/package.json @@ -0,0 +1,4 @@ +{ + "main": "dist/ui-avatar.cjs.js", + "module": "dist/ui-avatar.esm.js" +} diff --git a/packages/ui/badge/package.json b/packages/ui/badge/package.json new file mode 100644 index 000000000..180d027b2 --- /dev/null +++ b/packages/ui/badge/package.json @@ -0,0 +1,4 @@ +{ + "main": "dist/ui-badge.cjs.js", + "module": "dist/ui-badge.esm.js" +} diff --git a/packages/ui/button/package.json b/packages/ui/button/package.json new file mode 100644 index 000000000..1081a7e87 --- /dev/null +++ b/packages/ui/button/package.json @@ -0,0 +1,4 @@ +{ + "main": "dist/ui-button.cjs.js", + "module": "dist/ui-button.esm.js" +} diff --git a/packages/ui/calandar/package.json b/packages/ui/calandar/package.json new file mode 100644 index 000000000..513cc6c82 --- /dev/null +++ b/packages/ui/calandar/package.json @@ -0,0 +1,4 @@ +{ + "main": "dist/ui-calandar.cjs.js", + "module": "dist/ui-calandar.esm.js" +} diff --git a/packages/ui/card/package.json b/packages/ui/card/package.json new file mode 100644 index 000000000..9fe9a69b3 --- /dev/null +++ b/packages/ui/card/package.json @@ -0,0 +1,4 @@ +{ + "main": "dist/ui-card.cjs.js", + "module": "dist/ui-card.esm.js" +} diff --git a/packages/ui/checkbox/package.json b/packages/ui/checkbox/package.json new file mode 100644 index 000000000..19785872b --- /dev/null +++ b/packages/ui/checkbox/package.json @@ -0,0 +1,4 @@ +{ + "main": "dist/ui-checkbox.cjs.js", + "module": "dist/ui-checkbox.esm.js" +} diff --git a/packages/ui/collapsible/package.json b/packages/ui/collapsible/package.json new file mode 100644 index 000000000..7f5b38106 --- /dev/null +++ b/packages/ui/collapsible/package.json @@ -0,0 +1,4 @@ +{ + "main": "dist/ui-collapsible.cjs.js", + "module": "dist/ui-collapsible.esm.js" +} diff --git a/packages/ui/customRenderers/confidence/confidence/package.json b/packages/ui/customRenderers/confidence/confidence/package.json new file mode 100644 index 000000000..d7811aacd --- /dev/null +++ b/packages/ui/customRenderers/confidence/confidence/package.json @@ -0,0 +1,4 @@ +{ + "main": "dist/ui-customRenderers-confidence-confidence.cjs.js", + "module": "dist/ui-customRenderers-confidence-confidence.esm.js" +} diff --git a/packages/ui/customRenderers/fileUpload/fileUpload/package.json b/packages/ui/customRenderers/fileUpload/fileUpload/package.json new file mode 100644 index 000000000..115896971 --- /dev/null +++ b/packages/ui/customRenderers/fileUpload/fileUpload/package.json @@ -0,0 +1,4 @@ +{ + "main": "dist/ui-customRenderers-fileUpload-fileUpload.cjs.js", + "module": "dist/ui-customRenderers-fileUpload-fileUpload.esm.js" +} diff --git a/packages/ui/dialog/package.json b/packages/ui/dialog/package.json new file mode 100644 index 000000000..6c62c6c49 --- /dev/null +++ b/packages/ui/dialog/package.json @@ -0,0 +1,4 @@ +{ + "main": "dist/ui-dialog.cjs.js", + "module": "dist/ui-dialog.esm.js" +} diff --git a/packages/ui/dropdown-menu/package.json b/packages/ui/dropdown-menu/package.json new file mode 100644 index 000000000..19a93e342 --- /dev/null +++ b/packages/ui/dropdown-menu/package.json @@ -0,0 +1,4 @@ +{ + "main": "dist/ui-dropdown-menu.cjs.js", + "module": "dist/ui-dropdown-menu.esm.js" +} diff --git a/packages/ui/form/package.json b/packages/ui/form/package.json new file mode 100644 index 000000000..2d77a663e --- /dev/null +++ b/packages/ui/form/package.json @@ -0,0 +1,4 @@ +{ + "main": "dist/ui-form.cjs.js", + "module": "dist/ui-form.esm.js" +} diff --git a/packages/ui/hooks/package.json b/packages/ui/hooks/package.json new file mode 100644 index 000000000..ba1eb5669 --- /dev/null +++ b/packages/ui/hooks/package.json @@ -0,0 +1,4 @@ +{ + "main": "dist/ui-hooks.cjs.js", + "module": "dist/ui-hooks.esm.js" +} diff --git a/packages/ui/hover-card/package.json b/packages/ui/hover-card/package.json new file mode 100644 index 000000000..ef697bc22 --- /dev/null +++ b/packages/ui/hover-card/package.json @@ -0,0 +1,4 @@ +{ + "main": "dist/ui-hover-card.cjs.js", + "module": "dist/ui-hover-card.esm.js" +} diff --git a/packages/ui/icon/package.json b/packages/ui/icon/package.json new file mode 100644 index 000000000..a76332af5 --- /dev/null +++ b/packages/ui/icon/package.json @@ -0,0 +1,4 @@ +{ + "main": "dist/ui-icon.cjs.js", + "module": "dist/ui-icon.esm.js" +} diff --git a/packages/ui/input/package.json b/packages/ui/input/package.json new file mode 100644 index 000000000..420fbd174 --- /dev/null +++ b/packages/ui/input/package.json @@ -0,0 +1,4 @@ +{ + "main": "dist/ui-input.cjs.js", + "module": "dist/ui-input.esm.js" +} diff --git a/packages/ui/label/package.json b/packages/ui/label/package.json new file mode 100644 index 000000000..4c9190dc6 --- /dev/null +++ b/packages/ui/label/package.json @@ -0,0 +1,4 @@ +{ + "main": "dist/ui-label.cjs.js", + "module": "dist/ui-label.esm.js" +} diff --git a/packages/ui/package.json b/packages/ui/package.json index ac4bc0110..561b6096c 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -4,9 +4,163 @@ "version": "0.0.0", "main": "dist/ui.cjs.js", "module": "dist/ui.esm.js", + "exports": { + "./card": { + "module": "./card/dist/ui-card.esm.js", + "default": "./card/dist/ui-card.cjs.js" + }, + "./form": { + "module": "./form/dist/ui-form.esm.js", + "default": "./form/dist/ui-form.cjs.js" + }, + "./icon": { + "module": "./icon/dist/ui-icon.esm.js", + "default": "./icon/dist/ui-icon.cjs.js" + }, + "./tabs": { + "module": "./tabs/dist/ui-tabs.esm.js", + "default": "./tabs/dist/ui-tabs.cjs.js" + }, + "./alert": { + "module": "./alert/dist/ui-alert.esm.js", + "default": "./alert/dist/ui-alert.cjs.js" + }, + "./badge": { + "module": "./badge/dist/ui-badge.esm.js", + "default": "./badge/dist/ui-badge.cjs.js" + }, + "./input": { + "module": "./input/dist/ui-input.esm.js", + "default": "./input/dist/ui-input.cjs.js" + }, + "./label": { + "module": "./label/dist/ui-label.esm.js", + "default": "./label/dist/ui-label.cjs.js" + }, + "./toast": { + "module": "./toast/dist/ui-toast.esm.js", + "default": "./toast/dist/ui-toast.cjs.js" + }, + "./avatar": { + "module": "./avatar/dist/ui-avatar.esm.js", + "default": "./avatar/dist/ui-avatar.cjs.js" + }, + "./button": { + "module": "./button/dist/ui-button.esm.js", + "default": "./button/dist/ui-button.cjs.js" + }, + "./dialog": { + "module": "./dialog/dist/ui-dialog.esm.js", + "default": "./dialog/dist/ui-dialog.cjs.js" + }, + "./select": { + "module": "./select/dist/ui-select.esm.js", + "default": "./select/dist/ui-select.cjs.js" + }, + "./popover": { + "module": "./popover/dist/ui-popover.esm.js", + "default": "./popover/dist/ui-popover.cjs.js" + }, + "./toaster": { + "module": "./toaster/dist/ui-toaster.esm.js", + "default": "./toaster/dist/ui-toaster.cjs.js" + }, + "./tooltip": { + "module": "./tooltip/dist/ui-tooltip.esm.js", + "default": "./tooltip/dist/ui-tooltip.cjs.js" + }, + "./calandar": { + "module": "./calandar/dist/ui-calandar.esm.js", + "default": "./calandar/dist/ui-calandar.cjs.js" + }, + "./checkbox": { + "module": "./checkbox/dist/ui-checkbox.esm.js", + "default": "./checkbox/dist/ui-checkbox.cjs.js" + }, + "./textarea": { + "module": "./textarea/dist/ui-textarea.esm.js", + "default": "./textarea/dist/ui-textarea.cjs.js" + }, + "./separator": { + "module": "./separator/dist/ui-separator.esm.js", + "default": "./separator/dist/ui-separator.cjs.js" + }, + "./use-toast": { + "module": "./use-toast/dist/ui-use-toast.esm.js", + "default": "./use-toast/dist/ui-use-toast.cjs.js" + }, + "./hooks": { + "module": "./hooks/dist/ui-hooks.esm.js", + "default": "./hooks/dist/ui-hooks.cjs.js" + }, + "./hover-card": { + "module": "./hover-card/dist/ui-hover-card.esm.js", + "default": "./hover-card/dist/ui-hover-card.cjs.js" + }, + "./collapsible": { + "module": "./collapsible/dist/ui-collapsible.esm.js", + "default": "./collapsible/dist/ui-collapsible.cjs.js" + }, + "./dropdown-menu": { + "module": "./dropdown-menu/dist/ui-dropdown-menu.esm.js", + "default": "./dropdown-menu/dist/ui-dropdown-menu.cjs.js" + }, + "./customRenderers/confidence/confidence": { + "module": "./customRenderers/confidence/confidence/dist/ui-customRenderers-confidence-confidence.esm.js", + "default": "./customRenderers/confidence/confidence/dist/ui-customRenderers-confidence-confidence.cjs.js" + }, + "./customRenderers/fileUpload/fileUpload": { + "module": "./customRenderers/fileUpload/fileUpload/dist/ui-customRenderers-fileUpload-fileUpload.esm.js", + "default": "./customRenderers/fileUpload/fileUpload/dist/ui-customRenderers-fileUpload-fileUpload.cjs.js" + }, + "./package.json": "./package.json", + "./customRenderers/confidence": { + "module": "./customRenderers/confidence/confidence/dist/ui-customRenderers-confidence-confidence.esm.js", + "default": "./customRenderers/confidence/confidence/dist/ui-customRenderers-confidence-confidence.cjs.js" + }, + "./customRenderers/fileUpload": { + "module": "./customRenderers/fileUpload/fileUpload/dist/ui-customRenderers-fileUpload-fileUpload.esm.js", + "default": "./customRenderers/fileUpload/fileUpload/dist/ui-customRenderers-fileUpload-fileUpload.cjs.js" + }, + "./tailwind.config.js": { + "module": "./tailwind.config.js", + "default": "./tailwind.config.js" + }, + "./styles.css": { + "module": "./styles.css", + "default": "./styles.css" + } + }, "files": [ "dist", - "styles.css" + "styles.css", + "alert", + "avatar", + "badge", + "button", + "calandar", + "card", + "checkbox", + "collapsible", + "dialog", + "dropdown-menu", + "form", + "hover-card", + "icon", + "index", + "input", + "label", + "popover", + "select", + "separator", + "tabs", + "textarea", + "toast", + "toaster", + "tooltip", + "use-toast", + "hooks", + "customRenderers" ], "scripts": { "type-check": "tsc" @@ -56,7 +210,58 @@ "react": "^18.2.0", "react-hook-form": "^7.46.1", "tsconfig": "workspace:*", - "typescript": "^4.9.4", + "typescript": "^5.3.3", "zod": "^3.21.4" + }, + "preconstruct": { + "exports": { + "extra": { + "./customRenderers/confidence": { + "module": "./customRenderers/confidence/confidence/dist/ui-customRenderers-confidence-confidence.esm.js", + "default": "./customRenderers/confidence/confidence/dist/ui-customRenderers-confidence-confidence.cjs.js" + }, + "./customRenderers/fileUpload": { + "module": "./customRenderers/fileUpload/fileUpload/dist/ui-customRenderers-fileUpload-fileUpload.esm.js", + "default": "./customRenderers/fileUpload/fileUpload/dist/ui-customRenderers-fileUpload-fileUpload.cjs.js" + }, + "./tailwind.config.js": { + "module": "./tailwind.config.js", + "default": "./tailwind.config.js" + }, + "./styles.css": { + "module": "./styles.css", + "default": "./styles.css" + } + } + }, + "entrypoints": [ + "alert.tsx", + "avatar.tsx", + "badge.tsx", + "button.tsx", + "calandar.tsx", + "card.tsx", + "checkbox.tsx", + "collapsible.tsx", + "dialog.tsx", + "dropdown-menu.tsx", + "form.tsx", + "hover-card.tsx", + "icon.tsx", + "input.tsx", + "label.tsx", + "popover.tsx", + "select.tsx", + "separator.tsx", + "tabs.tsx", + "textarea.tsx", + "toast.tsx", + "toaster.tsx", + "tooltip.tsx", + "use-toast.tsx", + "hooks/index.ts", + "customRenderers/confidence/confidence.tsx", + "customRenderers/fileUpload/fileUpload.tsx" + ] } } diff --git a/packages/ui/popover/package.json b/packages/ui/popover/package.json new file mode 100644 index 000000000..6d810b0e6 --- /dev/null +++ b/packages/ui/popover/package.json @@ -0,0 +1,4 @@ +{ + "main": "dist/ui-popover.cjs.js", + "module": "dist/ui-popover.esm.js" +} diff --git a/packages/ui/select/package.json b/packages/ui/select/package.json new file mode 100644 index 000000000..e3370ae0e --- /dev/null +++ b/packages/ui/select/package.json @@ -0,0 +1,4 @@ +{ + "main": "dist/ui-select.cjs.js", + "module": "dist/ui-select.esm.js" +} diff --git a/packages/ui/separator/package.json b/packages/ui/separator/package.json new file mode 100644 index 000000000..f244fc904 --- /dev/null +++ b/packages/ui/separator/package.json @@ -0,0 +1,4 @@ +{ + "main": "dist/ui-separator.cjs.js", + "module": "dist/ui-separator.esm.js" +} diff --git a/packages/ui/src/index.tsx b/packages/ui/src/index.tsx deleted file mode 100644 index 2c851a98b..000000000 --- a/packages/ui/src/index.tsx +++ /dev/null @@ -1,34 +0,0 @@ -/* util/src/index.tsx */ - -/* Components */ -export * as Icon from "./icon"; -export * from "./alert"; -export * from "./avatar"; -export * from "./badge"; -export * from "./button"; -export * from "./card"; -export * from "./checkbox"; -export * from "./collapsible"; -export * from "./checkbox"; -export * from "./dialog"; -export * from "./dropdown-menu"; -export * from "./form"; -export * from "./hover-card"; -export * from "./input"; -export * from "./label"; -export * from "./popover"; -export * from "./select"; -export * from "./separator"; -export * from "./tabs"; -export * from "./textarea"; -export * from "./toast"; -export * from "./toaster"; -export * from "./tooltip"; -export * from "./use-toast"; - -/* Renderers */ -export * from "./customRenderers/confidence/confidence"; -export * from "./customRenderers/fileUpload/fileUpload"; - -/* Hooks */ -export * from "./hooks"; diff --git a/packages/ui/tabs/package.json b/packages/ui/tabs/package.json new file mode 100644 index 000000000..27b89bdf0 --- /dev/null +++ b/packages/ui/tabs/package.json @@ -0,0 +1,4 @@ +{ + "main": "dist/ui-tabs.cjs.js", + "module": "dist/ui-tabs.esm.js" +} diff --git a/packages/ui/textarea/package.json b/packages/ui/textarea/package.json new file mode 100644 index 000000000..965ab6525 --- /dev/null +++ b/packages/ui/textarea/package.json @@ -0,0 +1,4 @@ +{ + "main": "dist/ui-textarea.cjs.js", + "module": "dist/ui-textarea.esm.js" +} diff --git a/packages/ui/toast/package.json b/packages/ui/toast/package.json new file mode 100644 index 000000000..56c4eb416 --- /dev/null +++ b/packages/ui/toast/package.json @@ -0,0 +1,4 @@ +{ + "main": "dist/ui-toast.cjs.js", + "module": "dist/ui-toast.esm.js" +} diff --git a/packages/ui/toaster/package.json b/packages/ui/toaster/package.json new file mode 100644 index 000000000..b010e9efa --- /dev/null +++ b/packages/ui/toaster/package.json @@ -0,0 +1,4 @@ +{ + "main": "dist/ui-toaster.cjs.js", + "module": "dist/ui-toaster.esm.js" +} diff --git a/packages/ui/tooltip/package.json b/packages/ui/tooltip/package.json new file mode 100644 index 000000000..c9dd2a863 --- /dev/null +++ b/packages/ui/tooltip/package.json @@ -0,0 +1,4 @@ +{ + "main": "dist/ui-tooltip.cjs.js", + "module": "dist/ui-tooltip.esm.js" +} diff --git a/packages/ui/use-toast/package.json b/packages/ui/use-toast/package.json new file mode 100644 index 000000000..ce5a012ae --- /dev/null +++ b/packages/ui/use-toast/package.json @@ -0,0 +1,4 @@ +{ + "main": "dist/ui-use-toast.cjs.js", + "module": "dist/ui-use-toast.esm.js" +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3134f8a0b..184315e97 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,7 +24,7 @@ importers: specifier: ^2.26.2 version: 2.26.2 '@preconstruct/cli': - specifier: ^2.8.1 + specifier: ^2.8.3 version: 2.8.3 husky: specifier: ^8.0.3 @@ -564,8 +564,8 @@ importers: specifier: workspace:* version: link:../../config/tsconfig typescript: - specifier: ^4.9.4 - version: 4.9.4 + specifier: ^5.3.3 + version: 5.3.3 zod: specifier: ^3.21.4 version: 3.21.4 @@ -10956,6 +10956,12 @@ packages: engines: {node: '>=14.17'} hasBin: true + /typescript@5.3.3: + resolution: {integrity: sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==} + engines: {node: '>=14.17'} + hasBin: true + dev: true + /unbox-primitive@1.0.2: resolution: {integrity: sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==} dependencies: