Repository: ttab/elephant-chrome
Pull request #1185: Feature: duplicate event
Author: login: vilindh
Created: 2025-03-03T10:59:26Z | Merged: 2025-03-06T08:21:12Z



Diff:
diff --git a/src/components/Duplicate/DuplicatePrompt.tsx b/src/components/Duplicate/DuplicatePrompt.tsx
new file mode 100644
index 000000000..ed2e122cc
--- /dev/null
+++ b/src/components/Duplicate/DuplicatePrompt.tsx
@@ -0,0 +1,152 @@
+import { useCollaborationDocument } from '@/hooks/useCollaborationDocument'
+import { useKeydownGlobal } from '@/hooks/useKeydownGlobal'
+import type { HocuspocusProvider } from '@hocuspocus/provider'
+import {
+  Button,
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle
+} from '@ttab/elephant-ui'
+import { useMemo, type MouseEvent } from 'react'
+import { fromYjsNewsDoc, toYjsNewsDoc } from '../../../src-srv/utils/transformations/yjsNewsDoc'
+import { fromGroupedNewsDoc, toGroupedNewsDoc } from '../../../src-srv/utils/transformations/groupedNewsDoc'
+import { Block } from '@ttab/elephant-api/newsdoc'
+import * as Y from 'yjs'
+
+export const DuplicatePrompt = ({
+  provider,
+  duplicateDate,
+  title,
+  description,
+  primaryLabel,
+  secondaryLabel,
+  onPrimary,
+  onSecondary,
+  type
+}: {
+  title?: string
+  description: string
+  primaryLabel: string
+  secondaryLabel?: string
+  onPrimary: (duplicateId: string | undefined) => void
+  onSecondary?: (event: MouseEvent<HTMLButtonElement> | React.KeyboardEvent<HTMLButtonElement> | KeyboardEvent) => void
+  provider?: HocuspocusProvider
+  duplicateDate: Date
+  type: string
+}): JSX.Element => {
+  useKeydownGlobal((event) => {
+    if (event.key === 'Escape' && secondaryLabel && onSecondary) {
+      onSecondary(event as unknown as React.KeyboardEvent<HTMLButtonElement>)
+    }
+  })
+
+  function mergeDateWithTime(date1ISO: string | undefined, date2ISO: string | undefined) {
+    if (!date1ISO || !date2ISO) {
+      return ''
+    }
+
+    const [, originalTime] = date1ISO.split('T')
+    const [newDate] = date2ISO.split('T')
+    return `${newDate}T${originalTime}`
+  }
+
+  const collaborationPayload = useMemo(() => {
+    const documentId = crypto.randomUUID()
+    const yDoc = new Y.Doc()
+
+    if (provider) {
+      const newsdoc = fromGroupedNewsDoc(fromYjsNewsDoc(provider.document).documentResponse)
+      const originalUuid = newsdoc.document.uuid
+      newsdoc.document.uuid = documentId
+      newsdoc.document.uri = `core://${type}/${documentId}`
+      const eventBlock = newsdoc.document.meta.find((block) => block.type === 'core/event')
+
+      if (eventBlock) {
+        const newEventBlock = Block.create({
+          type: 'core/event',
+          data: {
+            ...eventBlock.data,
+            start: mergeDateWithTime(eventBlock?.data?.start, duplicateDate.toISOString()),
+            end: mergeDateWithTime(eventBlock?.data?.end, duplicateDate.toISOString())
+          }
+        })
+
+        newsdoc.document.meta = newsdoc.document.meta.filter((block) => block.type !== 'core/event')
+        newsdoc.document.meta.push(newEventBlock)
+
+        const copyGroup = newsdoc.document.meta.find((block) => block.type === 'core/copy-group')
+
+        // Use original document uuid as identifier for copy-groups...
+        if (!copyGroup) {
+          newsdoc.document.meta.push(Block.create({
+            type: 'core/copy-group',
+            uuid: originalUuid
+          }))
+          // ...or preserve already existing id if exists
+        }
+      }
+
+      newsdoc.version = 0n // Should the new version be 0? Or inherit old version number?
+
+      toYjsNewsDoc(
+        toGroupedNewsDoc(newsdoc),
+        yDoc
+      )
+      return { documentId, initialDocument: yDoc }
+    }
+
+    throw new Error('no provider')
+  }, [provider, type, duplicateDate])
+
+  const { documentId: duplicateId } = useCollaborationDocument(collaborationPayload)
+
+  return (
+    <Dialog open={true}>
+      <DialogContent
+        onOpenAutoFocus={(event) => event.preventDefault()}
+      >
+        <DialogHeader>
+          {!!title
+          && <DialogTitle>{title}</DialogTitle>}
+        </DialogHeader>
+
+        <DialogDescription>
+          {description}
+        </DialogDescription>
+
+        <DialogFooter className='flex flex-col gap-2 pt-4'>
+          {!!onSecondary && !!secondaryLabel
+          && (
+            <Button
+              variant='secondary'
+              onClick={(event) => {
+                event.preventDefault()
+                event.stopPropagation()
+                onSecondary(event)
+              }}
+            >
+              {secondaryLabel}
+            </Button>
+          )}
+
+          <Button
+            autoFocus
+            onClick={() => {
+              onPrimary(duplicateId)
+            }}
+            onKeyDown={(event: React.KeyboardEvent<HTMLButtonElement>) => {
+              if (event.key === 'Enter') {
+                onPrimary(duplicateId)
+              }
+            }}
+          >
+            {primaryLabel}
+          </Button>
+        </DialogFooter>
+      </DialogContent>
+    </Dialog>
+  )
+}
diff --git a/src/components/Duplicate/index.tsx b/src/components/Duplicate/index.tsx
new file mode 100644
index 000000000..d97fa44ba
--- /dev/null
+++ b/src/components/Duplicate/index.tsx
@@ -0,0 +1,97 @@
+import { Button, Calendar, Popover, PopoverContent, PopoverTrigger } from '@ttab/elephant-ui'
+import { DuplicatePrompt } from './DuplicatePrompt'
+import { addDays, format } from 'date-fns'
+import { createStateless, StatelessType } from '@/shared/stateless'
+import { useState } from 'react'
+import { toast } from 'sonner'
+import type { HocuspocusProvider } from '@hocuspocus/provider'
+import type { Session } from 'next-auth'
+import { CopyPlus } from '@ttab/elephant-ui/icons'
+import { ToastAction } from '@/views/Flash/ToastAction'
+
+type allowedTypes = 'Event'
+
+export const Duplicate = ({ provider, title, session, status, type }: {
+  provider: HocuspocusProvider
+  title: string | undefined
+  session: Session | null
+  status: 'authenticated' | 'loading' | 'unauthenticated'
+  type: 'event'
+}) => {
+  const [duplicateDate, setDuplicateDate] = useState<Date>(addDays(new Date(), 1))
+  const [showConfirm, setShowConfirm] = useState<boolean>(false)
+
+  return (
+    <div className='flex-row gap-2 justify-start items-center'>
+      <Popover>
+        <PopoverTrigger title='Kopiera'>
+          <div
+            className='flex items-center justify-center w-9 h-9 px-0 hover:bg-gray-200 dark:hover:bg-gray-700'
+          >
+            <CopyPlus size={20} strokeWidth={1.75} />
+          </div>
+        </PopoverTrigger>
+        <PopoverContent onEscapeKeyDown={(event) => event?.stopPropagation()}>
+          <Calendar
+            mode='single'
+            disabled={{ before: addDays(new Date(), 1) }}
+            selected={duplicateDate}
+            startMonth={new Date(duplicateDate)}
+            onSelect={(selectedDate) => {
+              if (!selectedDate) {
+                return
+              }
+              setDuplicateDate(new Date(format(selectedDate, 'yyyy-MM-dd')))
+            }}
+          />
+          <div className='flex w-full justify-end'>
+            <Button
+              onClick={() => setShowConfirm(!showConfirm)}
+            >
+              Kopiera
+            </Button>
+          </div>
+        </PopoverContent>
+      </Popover>
+      {showConfirm && (
+        <DuplicatePrompt
+          duplicateDate={duplicateDate}
+          provider={provider}
+          title={title || ''}
+          type={type}
+          description={`Vill du kopiera ${type === 'event' ? 'händelsen' : ''} till ${format(duplicateDate, 'dd/MM/yyyy')}?`}
+          secondaryLabel='Avbryt'
+          primaryLabel='Kopiera'
+          onPrimary={(duplicateId: string | undefined) => {
+            const capitalized: allowedTypes = type.slice(0, 1).toUpperCase().concat(type.slice(1)) as allowedTypes
+            if (provider && status === 'authenticated' && duplicateId && session) {
+              try {
+                provider.sendStateless(
+                  createStateless(StatelessType.IN_PROGRESS, {
+                    state: false,
+                    id: duplicateId,
+                    context: {
+                      accessToken: session.accessToken,
+                      user: session.user,
+                      type: capitalized
+                    }
+                  })
+                )
+                toast.success(`Händelsen "${title}" kopierades till ${format(duplicateDate, 'dd/MM/yyyy')}`, {
+                  action: <ToastAction eventId={duplicateId} />
+                })
+              } catch (error) {
+                toast.error(`Något gick fel: ${JSON.stringify(error)}`)
+                console.error(error)
+              }
+            }
+            setShowConfirm(false)
+          }}
+          onSecondary={() => {
+            setShowConfirm(false)
+          }}
+        />
+      )}
+    </div>
+  )
+}
diff --git a/src/components/Link/Link.tsx b/src/components/Link/Link.tsx
index 3243010dc..40c2334c4 100644
--- a/src/components/Link/Link.tsx
+++ b/src/components/Link/Link.tsx
@@ -10,7 +10,7 @@ interface LinkProps {
   to: View
   props?: ViewProps
   onClick?: (event: MouseEvent<HTMLAnchorElement>) => void
-  target?: 'self'
+  target?: 'self' | 'last'
   className?: string
 }
 
diff --git a/src/components/index.tsx b/src/components/index.tsx
index 458505556..ba72f0f48 100644
--- a/src/components/index.tsx
+++ b/src/components/index.tsx
@@ -19,4 +19,5 @@ export { Organiser } from './Organiser'
 export { Byline } from './Byline'
 export { Form } from './Form'
 export { Prompt } from './Prompt'
+export { DuplicatePrompt } from './Duplicate/DuplicatePrompt'
 export { UserMessage } from './UserMessage'
diff --git a/src/lib/events/index.ts b/src/lib/events/index.ts
index bbb67e76e..016945ad6 100644
--- a/src/lib/events/index.ts
+++ b/src/lib/events/index.ts
@@ -1,9 +1,11 @@
 import { search } from './search'
 import { relatedPlanningSearch } from './relatedPlanningSearch'
+import { listDuplicates } from '../listDuplicates'
 
 const Events = {
   search,
-  relatedPlanningSearch
+  relatedPlanningSearch,
+  listDuplicates
 }
 
 export { Events }
diff --git a/src/lib/events/search.ts b/src/lib/events/search.ts
index 350f459b3..2bb18432f 100644
--- a/src/lib/events/search.ts
+++ b/src/lib/events/search.ts
@@ -1,3 +1,4 @@
+import { subHours } from 'date-fns'
 import { type SearchIndexResponse, type Event } from '../index'
 import { searchIndex } from '../index'
 
@@ -43,20 +44,29 @@ export const search = async (endpoint: URL, accessToken: string, params?: EventS
     sort.push({ 'document.meta.core_newsvalue.value': 'desc' })
   }
 
-  if (params?.when === 'anytime') {
-    sort.push({ 'document.meta.core_event.data.start': 'desc' })
-  }
+  sort.push({ 'document.meta.core_event.data.start': 'asc' })
 
   const timeRange = params?.when === 'anytime'
     ? undefined
-    : {
-        range: {
-          'document.meta.core_event.data.start': {
-            gte: start.toISOString(),
-            lte: end.toISOString()
+    : [
+        {
+          range: {
+            'document.meta.core_event.data.start': {
+              gte: subHours(start.toISOString(), 1),
+              lte: subHours(end.toISOString(), 1)
+            }
+          }
+        },
+        {
+          range: {
+            'document.meta.core_event.data.end': {
+              gte: subHours(start.toISOString(), 1),
+              lte: subHours(end.toISOString(), 1)
+            }
           }
         }
-      }
+      ]
+
 
   const textCriteria = !params?.where?.text
     ? undefined
@@ -87,14 +97,8 @@ export const search = async (endpoint: URL, accessToken: string, params?: EventS
   const query = {
     query: {
       bool: {
-        must: [],
-        must_not: params?.when !== 'anytime'
-          ? [{
-              term: {
-                'document.meta.core_event.data.end': 'now+1d/d'
-              }
-            }]
-          : []
+        should: [],
+        must: []
       }
     },
     _source: true,
@@ -112,7 +116,7 @@ export const search = async (endpoint: URL, accessToken: string, params?: EventS
 
   if (timeRange) {
     // @ts-expect-error We don't have types for opensearch queries
-    query.query.bool.must.push(timeRange)
+    query.query.bool.should.push(...timeRange)
   }
 
   return await searchIndex(
diff --git a/src/lib/listDuplicates.ts b/src/lib/listDuplicates.ts
new file mode 100644
index 000000000..bb3db363d
--- /dev/null
+++ b/src/lib/listDuplicates.ts
@@ -0,0 +1,32 @@
+import { type Event, searchIndex, type SearchIndexResponse } from './index'
+
+export const listDuplicates = async (endpoint: URL, accessToken: string, documentId: string): Promise<SearchIndexResponse<Event>> => {
+  const query = {
+    query: {
+      bool: {
+        must: [
+          {
+            term: {
+              'document.meta.core_copy_group.uuid': documentId
+            }
+          }
+        ]
+      }
+    },
+    _source: true,
+    fields: [
+      'document.title',
+      'heads.usable.*',
+      'document.meta.core_event.data.start'
+    ]
+  }
+
+  return await searchIndex<Event>(
+    query,
+    {
+      index: 'core_event',
+      endpoint,
+      accessToken
+    }
+  )
+}
diff --git a/src/views/Event/EventHeader.tsx b/src/views/Event/EventHeader.tsx
index e22e62676..47f510b4f 100644
--- a/src/views/Event/EventHeader.tsx
+++ b/src/views/Event/EventHeader.tsx
@@ -2,11 +2,19 @@ import { useDocumentStatus, useView } from '@/hooks'
 import { useEffect, useRef } from 'react'
 import { DocumentStatus } from '@/components/TmpDocumentStatus'
 import { ViewHeader } from '@/components/View'
+import { Duplicate } from '@/components/Duplicate'
+import type { Session } from 'next-auth'
+import type { HocuspocusProvider } from '@hocuspocus/provider'
 
-export const EventHeader = ({ documentId, asDialog, onDialogClose }: {
+export const EventHeader = ({ documentId, asDialog, onDialogClose, provider, title, status, session, type }: {
   documentId: string
   asDialog: boolean
   onDialogClose?: () => void
+  title: string | undefined
+  session: Session | null
+  provider: HocuspocusProvider | undefined
+  type: 'event'
+  status: 'authenticated' | 'loading' | 'unauthenticated'
 }): JSX.Element => {
   const { viewId } = useView()
   const [documentStatus, setDocumentStatus] = useDocumentStatus(documentId)
@@ -25,8 +33,8 @@ export const EventHeader = ({ documentId, asDialog, onDialogClose }: {
       />
 
       <ViewHeader.Content className='justify-start'>
-        <div className='max-w-[850px] mx-auto flex flex-row gap-2 justify-between items-center w-full'>
-          <div className='flex flex-row gap-1 justify-start items-center @6xl/view:-ml-20'>
+        <div className='max-w-[850px] mx-auto flex flex-row gap-0 justify-between items-center w-full'>
+          <div className='flex flex-row gap-0 justify-start items-center @6xl/view:-ml-20'>
             <div className='hidden flex-row gap-2 justify-start items-center @xl/view:flex'>
               {/* No content as of now */}
             </div>
@@ -39,6 +47,15 @@ export const EventHeader = ({ documentId, asDialog, onDialogClose }: {
                 <DocumentStatus status={documentStatus} setStatus={setDocumentStatus} />
               </>
             )}
+            {!asDialog && provider && (
+              <Duplicate
+                title={title}
+                provider={provider}
+                session={session}
+                status={status}
+                type={type}
+              />
+            )}
           </div>
         </div>
       </ViewHeader.Content>
diff --git a/src/views/Event/components/DuplicatesTable.tsx b/src/views/Event/components/DuplicatesTable.tsx
new file mode 100644
index 000000000..672acac29
--- /dev/null
+++ b/src/views/Event/components/DuplicatesTable.tsx
@@ -0,0 +1,87 @@
+import { useIndexUrl } from '@/hooks/useIndexUrl'
+import { Events } from '@/lib/events'
+import { CalendarPlus2 } from '@ttab/elephant-ui/icons'
+import { useSession } from 'next-auth/react'
+import useSWR from 'swr'
+import type { FormProps } from '@/components/Form/Root'
+import { format } from 'date-fns'
+import { useRepositoryEvents } from '@/hooks/useRepositoryEvents'
+import { useRef } from 'react'
+import { Link } from '@/components/index'
+
+interface DuplicatesResult {
+  title: string | undefined
+  uuid: string | undefined
+  start: string | undefined
+}
+
+export const DuplicatesTable = ({ documentId }: {
+  documentId: string
+} & FormProps): JSX.Element => {
+  const { data: session, status } = useSession()
+  const indexUrl = useIndexUrl()
+  const createdDocumentIdRef = useRef<string | undefined>()
+
+  const { data, mutate, error } = useSWR<DuplicatesResult[] | undefined, Error>([
+    `duplicates/${documentId}`,
+    status,
+    indexUrl.href
+  ], async (): Promise<DuplicatesResult[] | undefined> => {
+    if (status !== 'authenticated') {
+      throw new Error('Not authenticated')
+    }
+
+    const statusResults = await Events.listDuplicates(indexUrl, session.accessToken, documentId)
+
+    return statusResults.hits.map((hit) => {
+      const [start] = hit._source['document.meta.core_event.data.start']
+      const [title] = hit._source['document.title']
+
+      return {
+        title,
+        uuid: hit._id,
+        start: format(new Date(start), 'yyyy-MM-dd')
+      }
+    })
+  })
+
+  useRepositoryEvents('core/event', (event) => {
+    if (createdDocumentIdRef.current === event.uuid && event.type === 'document') {
+      void (async () => {
+        try {
+          await mutate()
+        } catch (error) {
+          console.warn('Failed to update event item', error)
+        }
+      })()
+    }
+  })
+
+  if (!data) {
+    return <>Loading...</>
+  }
+
+  if (error) {
+    return <pre>{error.message}</pre>
+  }
+
+  if (!data?.length) {
+    return <></>
+  }
+
+  return (
+    <div className='pl-6 border-t'>
+      <div className='text-sm font-bold pt-2'>Duplicerade händelser</div>
+      {data?.map((duplicate: DuplicatesResult) => (
+        <div key={duplicate.uuid} className='pt-2'>
+          <Link to='Event' props={{ id: duplicate.uuid }} target='last'>
+            <div className='flex items-center gap-2'>
+              <CalendarPlus2 strokeWidth={1.75} size={18} className='text-muted-foreground' />
+              <div className='text-sm'>{duplicate.title}</div>
+            </div>
+          </Link>
+        </div>
+      ))}
+    </div>
+  )
+}
diff --git a/src/views/Event/index.tsx b/src/views/Event/index.tsx
index cedefc573..f3dd1997c 100644
--- a/src/views/Event/index.tsx
+++ b/src/views/Event/index.tsx
@@ -4,7 +4,8 @@ import { AwarenessDocument } from '@/components/AwarenessDocument'
 import {
   useCollaboration,
   useQuery,
-  useAwareness
+  useAwareness,
+  useYValue
 } from '@/hooks'
 import { useSession } from 'next-auth/react'
 import { View } from '@/components/View'
@@ -27,6 +28,7 @@ import { Form } from '@/components/Form'
 import { EventTimeMenu } from './components/EventTime'
 import { useEffect } from 'react'
 import { EventHeader } from './EventHeader'
+import { DuplicatesTable } from './components/DuplicatesTable'
 
 const meta: ViewMetadata = {
   name: 'Event',
@@ -74,6 +76,7 @@ const EventViewContent = (props: ViewProps & { documentId: string }): JSX.Elemen
   const { provider, user } = useCollaboration()
   const { data, status } = useSession()
   const [, setIsFocused] = useAwareness(props.documentId)
+  const [eventTitle] = useYValue<string | undefined>('root.title')
 
   useEffect(() => {
     provider?.setAwarenessField('data', user)
@@ -108,8 +111,16 @@ const EventViewContent = (props: ViewProps & { documentId: string }): JSX.Elemen
 
   return (
     <View.Root asDialog={props.asDialog} className={props.className}>
-      <EventHeader asDialog={!!props.asDialog} onDialogClose={props.onDialogClose} documentId={props.documentId} />
-
+      <EventHeader
+        asDialog={!!props.asDialog}
+        onDialogClose={props.onDialogClose}
+        documentId={props.documentId}
+        title={eventTitle}
+        provider={provider}
+        session={data}
+        type='event'
+        status={status}
+      />
       <View.Content className='max-w-[1000px] flex-auto'>
         <Form.Root asDialog={props.asDialog}>
           <Form.Content>
@@ -142,6 +153,7 @@ const EventViewContent = (props: ViewProps & { documentId: string }): JSX.Elemen
 
           <Form.Table>
             <PlanningTable provider={provider} asDialog={props.asDialog} documentId={props.documentId} />
+            <DuplicatesTable documentId={props.documentId} />
           </Form.Table>
 
           <Form.Footer>
diff --git a/src/views/Flash/ToastAction.tsx b/src/views/Flash/ToastAction.tsx
index 9ae60f162..a82f2d1f1 100644
--- a/src/views/Flash/ToastAction.tsx
+++ b/src/views/Flash/ToastAction.tsx
@@ -1,13 +1,16 @@
 import { useLink } from '@/hooks/useLink'
 import { Button, Tooltip } from '@ttab/elephant-ui'
-import { CalendarDaysIcon, FileInput } from '@ttab/elephant-ui/icons'
+import { CalendarDaysIcon, CalendarPlus2, FileInput } from '@ttab/elephant-ui/icons'
 
-export const ToastAction = ({ planningId, flashId }: {
+export const ToastAction = ({ planningId, flashId, eventId }: {
   planningId?: string
   flashId?: string
+  eventId?: string
 }): JSX.Element => {
   const openFlash = useLink('Flash')
   const openPlanning = useLink('Planning')
+  const openEvent = useLink('Event')
+
   return (
     <div className='flex flex-row w-full gap-2 justify-end'>
       {planningId && (
@@ -34,6 +37,18 @@ export const ToastAction = ({ planningId, flashId }: {
           </Button>
         </Tooltip>
       )}
+      {eventId && (
+        <Tooltip
+          content='Öppna händelse'
+        >
+          <Button
+            variant='icon'
+            onClick={(event) => openEvent(event, { id: eventId }, 'last')}
+          >
+            <CalendarPlus2 size={16} strokeWidth={1.75} />
+          </Button>
+        </Tooltip>
+      )}
     </div>
   )
 }


Reviews:
user: login: gularsson
body: As mentioned on the meeting we should probably add the functionality here:
![Screenshot_2025-03-03-14-25-27_20752](https://github.com/user-attachments/assets/71615ca3-889a-4682-8578-58ef8270366b)
And we should find another place for it in the view, not sure where though.
![2025-03-03_14-31](https://github.com/user-attachments/assets/c8a84353-c55d-4202-96cf-89240891756a)
Händelse-menu could be clickable, we could add a tripple-dot menu to the header, or we could copy the sheet used in editor. Non is perfect though.
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-03T13:32:52Z
user: login: gularsson
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-03T13:43:24Z
user: login: vilindh
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-03T14:15:27Z
user: login: vilindh
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-03T14:53:03Z
user: login: dannelundqvist
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-03T16:15:39Z
user: login: vilindh
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-04T08:46:35Z
user: login: gularsson
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-05T13:54:03Z
user: login: gularsson
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-05T19:04:50Z

Review comments:
diff_hunk: @@ -0,0 +1,89 @@
+import { Button, Calendar, Popover, PopoverContent, PopoverTrigger } from '@ttab/elephant-ui'
+import { DuplicatePrompt } from './DuplicatePrompt'
+import { addDays, format } from 'date-fns'
+import { createStateless, StatelessType } from '@/shared/stateless'
+import { useState } from 'react'
+import { toast } from 'sonner'
+import type { HocuspocusProvider } from '@hocuspocus/provider'
+import type { Session } from 'next-auth'
+
+export const Duplicate = ({ provider, title, session, status, type }: {
+  provider: HocuspocusProvider
+  title: string | undefined
+  session: Session | null
+  status: 'authenticated' | 'loading' | 'unauthenticated'
+  type: 'event'
+}) => {
+  const [duplicateDate, setDuplicateDate] = useState<Date>(addDays(new Date(), 1))
+  const [showConfirm, setShowConfirm] = useState<boolean>(false)
+
+  return (
+    <div className='flex-row gap-2 justify-start items-center'>
+      <Popover modal={true}>
+        <PopoverTrigger asChild>
+          <div className='flex w-fit px-10 pt-2'>
+            <Button variant='outline' size='xs'>Kopiera</Button>
+          </div>
+        </PopoverTrigger>
+        <PopoverContent onEscapeKeyDown={(event) => event?.stopPropagation()}>
+          <Calendar
+            mode='single'
+            disabled={{ before: addDays(new Date(), 1) }}
+            selected={duplicateDate}
+            startMonth={new Date(duplicateDate)}
+            onSelect={(selectedDate) => {
+              if (!selectedDate) {
+                return
+              }
+              setDuplicateDate(selectedDate)
+            }}
+          />
+          <div className='flex w-full justify-end'>
+            <Button
+              onClick={() => setShowConfirm(!showConfirm)}
+            >
+              Kopiera
+            </Button>
+          </div>
+        </PopoverContent>
+      </Popover>
+      {showConfirm && (
+        <DuplicatePrompt
+          duplicateDate={duplicateDate}
+          provider={provider}
+          title={title || ''}
+          description={`Vill du kopiera ${type === 'event' ? 'händelsen' : ''} till ${format(duplicateDate, 'dd/MM/yyyy')}?`}
+          secondaryLabel='Avbryt'
+          primaryLabel='Kopiera'
+          onPrimary={(duplicateId: string | undefined) => {
+            console.log('provider', provider)
+            if (provider && status === 'authenticated' && duplicateId && session) {
+              try {
+                console.log('sending', duplicateId)
path: src/components/Duplicate/index.tsx
user: login: gularsson
body: :broom: 
created_at: 2025-03-03T13:38:21Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,89 @@
+import { Button, Calendar, Popover, PopoverContent, PopoverTrigger } from '@ttab/elephant-ui'
+import { DuplicatePrompt } from './DuplicatePrompt'
+import { addDays, format } from 'date-fns'
+import { createStateless, StatelessType } from '@/shared/stateless'
+import { useState } from 'react'
+import { toast } from 'sonner'
+import type { HocuspocusProvider } from '@hocuspocus/provider'
+import type { Session } from 'next-auth'
+
+export const Duplicate = ({ provider, title, session, status, type }: {
+  provider: HocuspocusProvider
+  title: string | undefined
+  session: Session | null
+  status: 'authenticated' | 'loading' | 'unauthenticated'
+  type: 'event'
+}) => {
+  const [duplicateDate, setDuplicateDate] = useState<Date>(addDays(new Date(), 1))
+  const [showConfirm, setShowConfirm] = useState<boolean>(false)
+
+  return (
+    <div className='flex-row gap-2 justify-start items-center'>
+      <Popover modal={true}>
+        <PopoverTrigger asChild>
+          <div className='flex w-fit px-10 pt-2'>
+            <Button variant='outline' size='xs'>Kopiera</Button>
+          </div>
+        </PopoverTrigger>
+        <PopoverContent onEscapeKeyDown={(event) => event?.stopPropagation()}>
+          <Calendar
+            mode='single'
+            disabled={{ before: addDays(new Date(), 1) }}
+            selected={duplicateDate}
+            startMonth={new Date(duplicateDate)}
+            onSelect={(selectedDate) => {
+              if (!selectedDate) {
+                return
+              }
+              setDuplicateDate(selectedDate)
+            }}
+          />
+          <div className='flex w-full justify-end'>
+            <Button
+              onClick={() => setShowConfirm(!showConfirm)}
+            >
+              Kopiera
+            </Button>
+          </div>
+        </PopoverContent>
+      </Popover>
+      {showConfirm && (
+        <DuplicatePrompt
+          duplicateDate={duplicateDate}
+          provider={provider}
+          title={title || ''}
+          description={`Vill du kopiera ${type === 'event' ? 'händelsen' : ''} till ${format(duplicateDate, 'dd/MM/yyyy')}?`}
+          secondaryLabel='Avbryt'
+          primaryLabel='Kopiera'
+          onPrimary={(duplicateId: string | undefined) => {
+            console.log('provider', provider)
+            if (provider && status === 'authenticated' && duplicateId && session) {
+              try {
+                console.log('sending', duplicateId)
+                provider.sendStateless(
+                  createStateless(StatelessType.IN_PROGRESS, {
+                    state: false,
+                    id: duplicateId,
+                    context: {
+                      accessToken: session.accessToken,
+                      user: session.user,
+                      type: type.slice(0, 1).toUpperCase().concat(type.slice(1))
+                    }
+                  })
+                )
+                toast.success('Kopieringen lyckades!')
path: src/components/Duplicate/index.tsx
user: login: gularsson
body: Would be nice with a link to the event here
created_at: 2025-03-03T13:38:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,104 @@
+import {
+  Document,
+  Block,
+  type Document as DocumentType,
+  type Block as BlockType
+} from '@ttab/elephant-api/newsdoc'
+import { fromYjsNewsDoc } from '../../../src-srv/utils/transformations/yjsNewsDoc'
+import type { Doc } from 'yjs'
+import type { EleDocument } from '@/shared/types'
+
+/**
+ * Duplicate a document, by taking the original document as a parameter,
+ * alter values expected to be changed, apply them and return the rest of the document.
+ *
+ * @returns Document
+ */
+
+export function duplicateTemplate(id: string, payload: { type: string, newDate: string, document: Doc }): DocumentType {
+  const { documentResponse: { document: newsDocDocument } } = fromYjsNewsDoc(payload?.document)
+
+  function mergeDateWithTime(date1ISO: string, date2ISO: string) {
+    if (!date1ISO || !date2ISO) {
+      return ''
+    }
+
+    const [_, time] = date1ISO.split('T')
+    const [date] = date2ISO.split('T')
+    return `${date}T${time}`
+  }
+
+  function filterNonEmpty(obj: BlockType): Record<string, string | object> {
+    if (typeof obj !== 'object' || obj === null) {
+      return obj
+    }
+
+    const filteredObj: Record<string, string | object> = {}
+    for (const [key, value] of Object.entries(obj)) {
+      if (typeof value === 'object' && value !== null) {
+        const filteredValue: Block | object = filterNonEmpty(value as BlockType)
+        if (Object.keys(filteredValue).length > 0) {
+          filteredObj[key] = filteredValue
+        }
+      } else if (value !== '') {
+        filteredObj[key] = value
+      }
+    }
+
+    return filteredObj
+  }
+
+  const keys: Array<keyof EleDocument> = ['meta', 'links']
+
+  const doc: DocumentType = keys.reduce((newDoc, key) => {
+    if (!document || !newsDocDocument || !newsDocDocument[key]) {
+      return newDoc
+    }
+
+    const value: Record<string, BlockType[]> = newsDocDocument[key]
+    console.log('value', value)
path: src/defaults/templates/duplicateTemplate.ts
user: login: gularsson
body: :broom: 
created_at: 2025-03-03T13:39:33Z
author_association: CONTRIBUTOR
path: src/defaults/templates/duplicateTemplate.ts
user: login: gularsson
body: Did you try cloning the initial document? I still think it would be more terse and less error prone.
created_at: 2025-03-03T13:42:37Z
author_association: CONTRIBUTOR
path: src/defaults/templates/duplicateTemplate.ts
user: login: vilindh
body: Did try, didn't get it to work
created_at: 2025-03-03T14:15:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,89 @@
+import { Button, Calendar, Popover, PopoverContent, PopoverTrigger } from '@ttab/elephant-ui'
+import { DuplicatePrompt } from './DuplicatePrompt'
+import { addDays, format } from 'date-fns'
+import { createStateless, StatelessType } from '@/shared/stateless'
+import { useState } from 'react'
+import { toast } from 'sonner'
+import type { HocuspocusProvider } from '@hocuspocus/provider'
+import type { Session } from 'next-auth'
+
+export const Duplicate = ({ provider, title, session, status, type }: {
+  provider: HocuspocusProvider
+  title: string | undefined
+  session: Session | null
+  status: 'authenticated' | 'loading' | 'unauthenticated'
+  type: 'event'
+}) => {
+  const [duplicateDate, setDuplicateDate] = useState<Date>(addDays(new Date(), 1))
+  const [showConfirm, setShowConfirm] = useState<boolean>(false)
+
+  return (
+    <div className='flex-row gap-2 justify-start items-center'>
+      <Popover modal={true}>
+        <PopoverTrigger asChild>
+          <div className='flex w-fit px-10 pt-2'>
+            <Button variant='outline' size='xs'>Kopiera</Button>
+          </div>
+        </PopoverTrigger>
+        <PopoverContent onEscapeKeyDown={(event) => event?.stopPropagation()}>
+          <Calendar
+            mode='single'
+            disabled={{ before: addDays(new Date(), 1) }}
+            selected={duplicateDate}
+            startMonth={new Date(duplicateDate)}
+            onSelect={(selectedDate) => {
+              if (!selectedDate) {
+                return
+              }
+              setDuplicateDate(selectedDate)
+            }}
+          />
+          <div className='flex w-full justify-end'>
+            <Button
+              onClick={() => setShowConfirm(!showConfirm)}
+            >
+              Kopiera
+            </Button>
+          </div>
+        </PopoverContent>
+      </Popover>
+      {showConfirm && (
+        <DuplicatePrompt
+          duplicateDate={duplicateDate}
+          provider={provider}
+          title={title || ''}
+          description={`Vill du kopiera ${type === 'event' ? 'händelsen' : ''} till ${format(duplicateDate, 'dd/MM/yyyy')}?`}
+          secondaryLabel='Avbryt'
+          primaryLabel='Kopiera'
+          onPrimary={(duplicateId: string | undefined) => {
+            console.log('provider', provider)
+            if (provider && status === 'authenticated' && duplicateId && session) {
+              try {
+                console.log('sending', duplicateId)
+                provider.sendStateless(
+                  createStateless(StatelessType.IN_PROGRESS, {
+                    state: false,
+                    id: duplicateId,
+                    context: {
+                      accessToken: session.accessToken,
+                      user: session.user,
+                      type: type.slice(0, 1).toUpperCase().concat(type.slice(1))
+                    }
+                  })
+                )
+                toast.success('Kopieringen lyckades!')
path: src/components/Duplicate/index.tsx
user: login: vilindh
body: Added new commit that does this
created_at: 2025-03-03T14:53:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,103 @@
+import {
+  Document,
+  Block,
+  type Document as DocumentType,
+  type Block as BlockType
+} from '@ttab/elephant-api/newsdoc'
+import { fromYjsNewsDoc } from '../../../src-srv/utils/transformations/yjsNewsDoc'
+import type { Doc } from 'yjs'
+import type { EleDocument } from '@/shared/types'
+
+/**
+ * Duplicate a document, by taking the original document as a parameter,
+ * alter values expected to be changed, apply them and return the rest of the document.
+ *
+ * @returns Document
+ */
+
+export function duplicateTemplate(id: string, payload: { type: string, newDate: string, document: Doc }): DocumentType {
+  const { documentResponse: { document: newsDocDocument } } = fromYjsNewsDoc(payload?.document)
+
+  function mergeDateWithTime(date1ISO: string, date2ISO: string) {
+    if (!date1ISO || !date2ISO) {
+      return ''
+    }
+
+    const [_, time] = date1ISO.split('T')
path: src/defaults/templates/duplicateTemplate.ts
user: login: dannelundqvist
body: Not that it matters, but there is no real reason to use _ for this case. It could be written as
```typescript
const [, time] = date1ISO.split('T')
```
created_at: 2025-03-03T16:15:39Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,103 @@
+import {
+  Document,
+  Block,
+  type Document as DocumentType,
+  type Block as BlockType
+} from '@ttab/elephant-api/newsdoc'
+import { fromYjsNewsDoc } from '../../../src-srv/utils/transformations/yjsNewsDoc'
+import type { Doc } from 'yjs'
+import type { EleDocument } from '@/shared/types'
+
+/**
+ * Duplicate a document, by taking the original document as a parameter,
+ * alter values expected to be changed, apply them and return the rest of the document.
+ *
+ * @returns Document
+ */
+
+export function duplicateTemplate(id: string, payload: { type: string, newDate: string, document: Doc }): DocumentType {
+  const { documentResponse: { document: newsDocDocument } } = fromYjsNewsDoc(payload?.document)
+
+  function mergeDateWithTime(date1ISO: string, date2ISO: string) {
+    if (!date1ISO || !date2ISO) {
+      return ''
+    }
+
+    const [_, time] = date1ISO.split('T')
path: src/defaults/templates/duplicateTemplate.ts
user: login: vilindh
body: fixed in new commit
created_at: 2025-03-04T08:46:35Z
author_association: CONTRIBUTOR

PR comments:
user: login: dannelundqvist
created_at: 2025-03-03T15:47:57Z
body: > As mentioned on the meeting we should probably add the functionality here: ![Screenshot_2025-03-03-14-25-27_20752]

I would suggest we simply start with moving the button to the header (and maybe then adjust borders and background if needed to make it look nice). Then we can think harder on how to incorporate a dot menu or something else as a pattern for all views.

<img width="936" alt="Skärmavbild 2025-03-03 kl  16 44 29" src="https://github.com/user-attachments/assets/789acc42-2c7a-4ccf-addb-b234329123ee" />

author_association: COLLABORATOR
user: login: dannelundqvist
created_at: 2025-03-03T15:55:00Z
body: I think we should assume things work out as we want it. Something like `Händelsen "Pressträff om ny generaldirektör" kopierades till 2025-03-12` would be more useful to the user and remind the user where the copy went.

When/if we later collect all notifications in a notification center/archive that text would also be more useful than five notifications that says "Kopiering lyckades!". We want to establish a pattern for notification texts.

<img width="440" alt="Skärmavbild 2025-03-03 kl  16 49 54" src="https://github.com/user-attachments/assets/0bdd4a77-1268-44ed-9911-71a091bdecc3" />
author_association: COLLABORATOR
user: login: dannelundqvist
created_at: 2025-03-03T15:56:54Z
body: > I think we should assume things work out as we want it. Something like `Händelsen "Pressträff om ny generaldirektör" kopierades till 2025-03-12` would be more useful to the user and remind the user where the copy went.



author_association: COLLABORATOR
user: login: vilindh
created_at: 2025-03-04T08:48:27Z
body: Added new commit that moves it into the view header. Also, replaced the button-esque trigger with a suitable icon instead, to save some space.

<img width="724" alt="Skärmavbild 2025-03-04 kl  12 40 08" src="https://github.com/user-attachments/assets/712b6a63-f501-4c25-9f28-a8d088ef36da" />


>  Händelse-menu could be clickable, we could add a tripple-dot menu to the header, or we could copy the sheet used in editor. Non is perfect though.


author_association: CONTRIBUTOR