diff --git a/apps/dashboard/src/app/(internal)/brukere/queries.ts b/apps/dashboard/src/app/(internal)/brukere/queries.ts index fafa0c1dff..f93cdb3296 100644 --- a/apps/dashboard/src/app/(internal)/brukere/queries.ts +++ b/apps/dashboard/src/app/(internal)/brukere/queries.ts @@ -16,13 +16,16 @@ interface UseUserAllQueryProps { page?: Pageable } -export const useUserAllQuery = ({ filter, page }: UseUserAllQueryProps) => { +export const useUserAllQuery = ({ filter, page }: UseUserAllQueryProps, options?: Parameters[2]) => { const trpc = useTRPC() const { data, isLoading } = useQuery( - trpc.user.all.queryOptions({ - filter, - ...page, - }) + trpc.user.all.queryOptions( + { + filter, + ...page, + }, + options + ) ) return { users: useMemo(() => data?.items ?? [], [data]), isLoading } } diff --git a/apps/dashboard/src/app/(internal)/varslinger/[id]/edit-card.tsx b/apps/dashboard/src/app/(internal)/varslinger/[id]/edit-card.tsx index 19b907292c..22866696c4 100644 --- a/apps/dashboard/src/app/(internal)/varslinger/[id]/edit-card.tsx +++ b/apps/dashboard/src/app/(internal)/varslinger/[id]/edit-card.tsx @@ -2,6 +2,7 @@ import type { FC } from "react" import { useEditNotificationMutation } from "../mutations" import { useNotificationWriteForm } from "../write-form" import { useNotificationDetailsContext } from "./provider" + export const NotificationEditCard: FC = () => { const { notification } = useNotificationDetailsContext() const edit = useEditNotificationMutation() @@ -10,9 +11,9 @@ export const NotificationEditCard: FC = () => { label: "Oppdater varsling", onSubmit: (data) => { const { ...notificationData } = data - edit.mutate({ id: notification.id, input: notificationData}) + edit.mutate({ id: notification.id, input: notificationData }) }, - defaultValues: { ...notification } + defaultValues: { ...notification }, }) return } diff --git a/apps/dashboard/src/app/(internal)/varslinger/[id]/layout.tsx b/apps/dashboard/src/app/(internal)/varslinger/[id]/layout.tsx index 9764e9a6f5..68237812ec 100644 --- a/apps/dashboard/src/app/(internal)/varslinger/[id]/layout.tsx +++ b/apps/dashboard/src/app/(internal)/varslinger/[id]/layout.tsx @@ -1,4 +1,5 @@ "use client" + import { Loader } from "@mantine/core" import { type PropsWithChildren, use, useMemo } from "react" import { NotificationDetailsContext } from "./provider" diff --git a/apps/dashboard/src/app/(internal)/varslinger/[id]/provider.tsx b/apps/dashboard/src/app/(internal)/varslinger/[id]/provider.tsx index 96a7656e1c..0b38dae6c6 100644 --- a/apps/dashboard/src/app/(internal)/varslinger/[id]/provider.tsx +++ b/apps/dashboard/src/app/(internal)/varslinger/[id]/provider.tsx @@ -1,7 +1,7 @@ "use client" -import type { Notification } from "@dotkomonline/types" import { createContext, useContext } from "react" +import type { Notification } from "@dotkomonline/rpc" export const NotificationDetailsContext = createContext<{ notification: Notification diff --git a/apps/dashboard/src/app/(internal)/varslinger/all-notification-table.tsx b/apps/dashboard/src/app/(internal)/varslinger/all-notification-table.tsx index dc30fd0ad9..7b4984cbc2 100644 --- a/apps/dashboard/src/app/(internal)/varslinger/all-notification-table.tsx +++ b/apps/dashboard/src/app/(internal)/varslinger/all-notification-table.tsx @@ -3,8 +3,8 @@ import { Anchor } from "@mantine/core" import { createColumnHelper, getCoreRowModel, useReactTable } from "@tanstack/react-table" import Link from "next/link" import { useMemo } from "react" -import { mapNotificationPayloadTypeToLabel, mapNotificationTypeToLabel, Notification } from "node_modules/@dotkomonline/rpc/src/modules/notification/notification" import { DateTooltip } from "@/components/DateTooltip" +import { mapNotificationPayloadTypeToLabel, mapNotificationTypeToLabel, type Notification } from "@dotkomonline/rpc" interface AllNotificationsTableProps { notifications: Notification[] @@ -25,7 +25,7 @@ export const AllNotificationsTable = ({ notifications }: AllNotificationsTablePr ), }), - columnHelper.accessor((notification) => notification.shortDescription, { + columnHelper.accessor((notification) => notification.shortDescription, { id: "shortDescription", header: () => "Kort beskrivelse", cell: (info) => info.getValue(), @@ -52,7 +52,6 @@ export const AllNotificationsTable = ({ notifications }: AllNotificationsTablePr }), ], [columnHelper] - ) const tableOptions = useMemo( @@ -65,9 +64,5 @@ export const AllNotificationsTable = ({ notifications }: AllNotificationsTablePr ) const table = useReactTable(tableOptions) - return ( - - ) + return } diff --git a/apps/dashboard/src/app/(internal)/varslinger/modals/create-notification.tsx b/apps/dashboard/src/app/(internal)/varslinger/modals/create-notification.tsx index bf1384fa09..48ee29beeb 100644 --- a/apps/dashboard/src/app/(internal)/varslinger/modals/create-notification.tsx +++ b/apps/dashboard/src/app/(internal)/varslinger/modals/create-notification.tsx @@ -8,10 +8,7 @@ export const CreateNotificationModal: FC = ({ context, id }) const create = useCreateNotificationMutation() const FormComponent = useNotificationWriteForm({ onSubmit: (data) => { - create.mutate( - data - ), - close() + create.mutate(data), close() }, }) return diff --git a/apps/dashboard/src/app/(internal)/varslinger/mutations.ts b/apps/dashboard/src/app/(internal)/varslinger/mutations.ts index 5663479974..b6f7ac1d20 100644 --- a/apps/dashboard/src/app/(internal)/varslinger/mutations.ts +++ b/apps/dashboard/src/app/(internal)/varslinger/mutations.ts @@ -1,4 +1,3 @@ -import { env } from "@/lib/env" import { useQueryNotification } from "@/lib/notifications" import { useTRPC } from "@/lib/trpc-client" import { useMutation, useQueryClient } from "@tanstack/react-query" @@ -16,8 +15,7 @@ export const useCreateNotificationMutation = () => { }) }, onSuccess: async (data) => { - await queryClient.invalidateQueries({ queryKey: trpc.notification.findMany.queryKey() }); - + await queryClient.invalidateQueries({ queryKey: trpc.notification.findMany.queryKey() }) notification.complete({ title: "Varsling opprettet", @@ -64,4 +62,3 @@ export const useEditNotificationMutation = () => { }) ) } - diff --git a/apps/dashboard/src/app/(internal)/varslinger/page.tsx b/apps/dashboard/src/app/(internal)/varslinger/page.tsx index c107ed80a0..e3e90c2a09 100644 --- a/apps/dashboard/src/app/(internal)/varslinger/page.tsx +++ b/apps/dashboard/src/app/(internal)/varslinger/page.tsx @@ -3,11 +3,10 @@ import { Box, Button, Skeleton, Stack } from "@mantine/core" import { AllNotificationsTable } from "./all-notification-table" import { useCreateNotificationModal } from "./modals/create-notification" -import { useNotificationAllQuery } from "./queries" - +import { useNotificationAllInfiniteQuery } from "./queries" export default function NotificationPage() { - const { notifications, isLoading: isNotificationsLoading } = useNotificationAllQuery() + const { notifications, isLoading: isNotificationsLoading } = useNotificationAllInfiniteQuery() const open = useCreateNotificationModal() return ( diff --git a/apps/dashboard/src/app/(internal)/varslinger/queries.ts b/apps/dashboard/src/app/(internal)/varslinger/queries.ts index e20309fecc..8a5799187e 100644 --- a/apps/dashboard/src/app/(internal)/varslinger/queries.ts +++ b/apps/dashboard/src/app/(internal)/varslinger/queries.ts @@ -1,12 +1,12 @@ import { useTRPC } from "@/lib/trpc-client" -import { Pageable } from "@dotkomonline/rpc" +import type { Pageable } from "@dotkomonline/utils" import { useInfiniteQuery } from "@tanstack/react-query" import { useMemo } from "react" -export const useNotificationAllQuery = (page?: Pageable) => { +export const useNotificationAllInfiniteQuery = (page?: Pageable) => { const trpc = useTRPC() - const { data, ...query } = useInfiniteQuery({ + const { data, ...query } = useInfiniteQuery({ ...trpc.notification.findMany.infiniteQueryOptions({ ...page }), getNextPageParam: (lastPage) => lastPage.nextCursor, select: (res) => res.pages.flatMap((p) => p.items), @@ -14,5 +14,3 @@ export const useNotificationAllQuery = (page?: Pageable) => { return { notifications: useMemo(() => data ?? [], [data]), ...query } } - - diff --git a/apps/dashboard/src/app/(internal)/varslinger/write-form.tsx b/apps/dashboard/src/app/(internal)/varslinger/write-form.tsx index 48c379147f..e2acf087db 100644 --- a/apps/dashboard/src/app/(internal)/varslinger/write-form.tsx +++ b/apps/dashboard/src/app/(internal)/varslinger/write-form.tsx @@ -2,21 +2,23 @@ import { useFormBuilder } from "@/components/forms/Form" import { createRichTextInput } from "@/components/forms/RichTextInput/RichTextInput" import { createSelectInput } from "@/components/forms/SelectInput" import { createTextInput } from "@/components/forms/TextInput" -import { mapNotificationPayloadTypeToLabel, mapNotificationTypeToLabel, NotificationPayloadTypeSchema, NotificationTypeSchema, NotificationWriteSchema } from "node_modules/@dotkomonline/rpc/src/modules/notification/notification" -import { type z } from "zod" +import type { z } from "zod" import { useGroupAllQuery } from "../grupper/queries" +import { createSearchableSelectInput } from "@/components/forms/SearchableSelectInput" +import { useUserSearch } from "@/components/forms/hooks/useUserSearch" +import { + mapNotificationPayloadTypeToLabel, + mapNotificationTypeToLabel, + NotificationPayloadTypeSchema, + NotificationTypeSchema, + NotificationWriteSchema, +} from "@dotkomonline/rpc" -const NOTIFICATION_FORM_DATA_TYPE = Object.values(NotificationTypeSchema.Values).map((type) => ({ - value: type, - label: mapNotificationTypeToLabel(type), -})) - -const NOTIFICATION_FORM_DATA_PAYLOAD_TYPE = Object.values(NotificationPayloadTypeSchema.Values).map((type) => ({ - value: type, - label: mapNotificationPayloadTypeToLabel(type), -})) - -const NOTIFICATION_FORM_DEFAULT_VALUES: Partial = {recipientIds: [], taskId: null } +const NOTIFICATION_FORM_DEFAULT_VALUES: Partial = { + recipientIds: [], + taskId: null, + payloadType: "NONE", +} type NotificationWriteFormSchema = z.infer @@ -31,7 +33,6 @@ export const useNotificationWriteForm = ({ label = "Legg inn ny varsling", defaultValues = NOTIFICATION_FORM_DEFAULT_VALUES, }: UseNotificationWriteFormProps) => { - const { groups } = useGroupAllQuery() return useFormBuilder({ @@ -40,6 +41,15 @@ export const useNotificationWriteForm = ({ onSubmit, label, fields: { + recipientIds: createSearchableSelectInput({ + multiSelect: true, + useSearchHook: useUserSearch, + dataMapper: (user) => ({ value: user.id, label: `${user.name} (${user.email})` }), + selectProps: { + label: "Mottakere", + placeholder: "Søk etter brukere", + }, + }), title: createTextInput({ label: "Tittel", placeholder: "Juleball Påmeldingen er åpen!", @@ -50,34 +60,40 @@ export const useNotificationWriteForm = ({ placeholder: "En kort beskrivelse av varslingen", required: true, }), - content: createRichTextInput({ + content: createRichTextInput({ label: "Innhold", required: true, }), - type: createSelectInput ({ - data: NOTIFICATION_FORM_DATA_TYPE, + type: createSelectInput({ + data: Object.values(NotificationTypeSchema.Values).map((type) => ({ + value: type, + label: mapNotificationTypeToLabel(type), + })), label: "Type", placeholder: "Velg type", required: true, }), - payload: createTextInput ({ + payloadType: createSelectInput({ + label: "Payload Type", + data: Object.values(NotificationPayloadTypeSchema.Values).map((type) => ({ + value: type, + label: mapNotificationPayloadTypeToLabel(type), + })), + placeholder: "Velg type", + required: true, + }), + payload: createTextInput({ label: "Payload", - placeholder: "Paylaod", + placeholder: "Payload", required: false, }), - payloadType: createSelectInput ({ - label: "payload type", - data: NOTIFICATION_FORM_DATA_PAYLOAD_TYPE, - placeholder: "Velg type", + actorGroupId: createSelectInput({ + label: "Ansvarlig gruppe", + placeholder: "Velg gruppe", + data: groups.map((group) => ({ value: group.slug, label: group.abbreviation })), + searchable: true, required: true, }), - actorGroupId: createSelectInput({ - label: "Ansvarlig gruppe", - placeholder: "Velg gruppe", - data: groups.map((group) => ({ value: group.slug, label: group.abbreviation })), - searchable: true, - required: true, - }), }, }) -} \ No newline at end of file +} diff --git a/apps/dashboard/src/components/forms/SearchableSelectInput.tsx b/apps/dashboard/src/components/forms/SearchableSelectInput.tsx new file mode 100644 index 0000000000..b7d6d99859 --- /dev/null +++ b/apps/dashboard/src/components/forms/SearchableSelectInput.tsx @@ -0,0 +1,172 @@ +import { ErrorMessage } from "@hookform/error-message" +import { Loader, MultiSelect, type MultiSelectProps, Select, type SelectProps } from "@mantine/core" +import { useDebouncedValue } from "@mantine/hooks" +import { useRef, useState } from "react" +import { Controller, type FieldValues, useController } from "react-hook-form" +import type { InputProducerResult } from "./types" + +export interface SelectOption { + value: string + label: string +} + +interface UseSearchHookResult { + data: T[] + isLoading: boolean +} + +type UseSearchHook = (searchQuery: string) => UseSearchHookResult +type DataMapper = (item: T) => SelectOption +type GetSelectedItem = (id: string) => T | undefined + +interface BaseProps { + /** Hook that performs the search query. Receives the debounced search string. */ + useSearchHook: UseSearchHook + /** Maps an item from the search results to a select option */ + dataMapper: DataMapper + /** Optional function to get a selected item by ID (for displaying selected items not in search results) */ + getSelectedItem?: GetSelectedItem + /** Debounce delay in milliseconds. Defaults to 300. */ + debounceMs?: number +} + +interface SingleSelectProps extends BaseProps { + multiSelect?: false + selectProps?: Omit< + SelectProps, + "data" | "value" | "onChange" | "error" | "searchable" | "searchValue" | "onSearchChange" + > +} + +interface MultiSelectInputProps extends BaseProps { + multiSelect: true + selectProps?: Omit< + MultiSelectProps, + "data" | "value" | "onChange" | "error" | "searchable" | "searchValue" | "onSearchChange" + > +} + +type Props = SingleSelectProps | MultiSelectInputProps + +export function createSearchableSelectInput({ + useSearchHook, + dataMapper, + getSelectedItem, + debounceMs = 300, + ...rest +}: Props): InputProducerResult { + const isMultiSelect = "multiSelect" in rest && rest.multiSelect === true + + return function FormSearchableSelectInput({ name, state, control }) { + const [searchQuery, setSearchQuery] = useState("") + const [debouncedSearchQuery] = useDebouncedValue(searchQuery, debounceMs) + + // Cache to store selected items for display when they're not in search results + const selectedItemsCache = useRef>(new Map()) + + const { data, isLoading } = useSearchHook(debouncedSearchQuery) + const { field } = useController({ name, control }) + + const options = data.map(dataMapper) + + // Cache items from current search results that match selected values + const selectedValues = isMultiSelect ? ((field.value as string[]) ?? []) : field.value ? [field.value] : [] + for (const item of data) { + const mapped = dataMapper(item) + if (selectedValues.includes(mapped.value)) { + selectedItemsCache.current.set(mapped.value, item) + } + } + + // Helper to find an item for a selected value + const findSelectedItem = (id: string): T | undefined => { + // Check cache first + const cachedItem = selectedItemsCache.current.get(id) + if (cachedItem) { + return cachedItem + } + + // Try getSelectedItem if provided + if (getSelectedItem) { + const item = getSelectedItem(id) + if (item) { + selectedItemsCache.current.set(id, item) + return item + } + } + + return undefined + } + + // For single select: ensure selected value is in options + if (!isMultiSelect && field.value) { + if (!options.some((o) => o.value === field.value)) { + const selectedItem = findSelectedItem(field.value) + if (selectedItem) { + options.push(dataMapper(selectedItem)) + } + } + } + + // For multi select: ensure all selected values are in options + if (isMultiSelect && Array.isArray(field.value)) { + const selectedIds = field.value as string[] + for (const selectedId of selectedIds) { + if (!options.some((o) => o.value === selectedId)) { + const selectedItem = findSelectedItem(selectedId) + if (selectedItem) { + options.push(dataMapper(selectedItem)) + } + } + } + } + + const handleSearch = (query: string) => { + setSearchQuery(query) + } + + if (isMultiSelect) { + const multiProps = (rest as MultiSelectInputProps).selectProps + return ( + ( + : undefined} + error={state.errors[name] && } + /> + )} + /> + ) + } + + const singleProps = (rest as SingleSelectProps).selectProps + return ( + ( +