From 6652d9eeca218bc0fb9a8002a86752605382e279 Mon Sep 17 00:00:00 2001 From: Daniel Eriksson Date: Fri, 3 May 2024 11:21:30 +0200 Subject: [PATCH] Add timeline history for maltekstseksjoner --- .../src/components/date-picker/constants.ts | 1 + .../maltekstseksjon/draft/draft.tsx | 17 +- .../maltekstseksjon/list-item.tsx | 14 +- .../maltekstseksjon-published.tsx | 17 +- .../maltekstseksjon-versions.tsx | 24 +- .../maltekstseksjon/maltekstseksjon.tsx | 2 +- .../maltekstseksjon/preview.tsx | 29 ++- .../maltekstseksjon/texts.tsx | 7 +- .../maltekstseksjon/timeline/pin-time.tsx | 51 +++++ .../maltekstseksjon/timeline/timeline.tsx | 206 ++++++++++++++++++ .../maltekstseksjon/timeline/use-timeline.tsx | 183 ++++++++++++++++ .../texts/published-rich-text.tsx | 2 +- .../texts/text-draft/language-editor.tsx | 2 +- .../texts/text-draft/text-draft.tsx | 10 +- .../maltekstseksjoner/texts/text-list.tsx | 6 +- .../maltekstseksjoner/texts/text-versions.tsx | 31 ++- .../components/text-history/text-history.tsx | 2 +- frontend/src/components/time/time.tsx | 13 ++ .../components/versioned-tabs/tab-label.tsx | 11 +- .../versioned-tabs/versioned-tabs.tsx | 16 +- frontend/src/functions/omit.ts | 9 + .../plate/components/placeholder/helpers.ts | 6 + frontend/src/redux-api/texts/queries.ts | 9 +- 23 files changed, 615 insertions(+), 53 deletions(-) create mode 100644 frontend/src/components/maltekstseksjoner/maltekstseksjon/timeline/pin-time.tsx create mode 100644 frontend/src/components/maltekstseksjoner/maltekstseksjon/timeline/timeline.tsx create mode 100644 frontend/src/components/maltekstseksjoner/maltekstseksjon/timeline/use-timeline.tsx create mode 100644 frontend/src/components/time/time.tsx create mode 100644 frontend/src/functions/omit.ts diff --git a/frontend/src/components/date-picker/constants.ts b/frontend/src/components/date-picker/constants.ts index 790cf1684..f03a4e312 100644 --- a/frontend/src/components/date-picker/constants.ts +++ b/frontend/src/components/date-picker/constants.ts @@ -1,5 +1,6 @@ export const CURRENT_YEAR_IN_CENTURY = Number.parseInt(new Date().getFullYear().toString().slice(2), 10); export const ISO_FORMAT = 'yyyy-MM-dd'; export const PRETTY_FORMAT = 'dd.MM.yyyy'; +export const PRETTY_DATETIME_FORMAT = 'dd.MM.yyyy HH:mm:ss'; export const FORMAT = 'yyyy-MM-dd'; export const ISO_DATETIME_FORMAT = "yyyy-MM-dd'T'HH:mm:ss"; diff --git a/frontend/src/components/maltekstseksjoner/maltekstseksjon/draft/draft.tsx b/frontend/src/components/maltekstseksjoner/maltekstseksjon/draft/draft.tsx index 6f7d9b5dc..f8a0836a7 100644 --- a/frontend/src/components/maltekstseksjoner/maltekstseksjon/draft/draft.tsx +++ b/frontend/src/components/maltekstseksjoner/maltekstseksjon/draft/draft.tsx @@ -5,6 +5,7 @@ import { EditableTitle } from '@app/components/editable-title/editable-title'; import { EditorName } from '@app/components/editor-name/editor-name'; import { Filters } from '@app/components/maltekstseksjoner/filters'; import { MaltekstseksjonTexts } from '@app/components/maltekstseksjoner/maltekstseksjon/texts'; +import { MaltekstHistoryModal } from '@app/components/maltekstseksjoner/maltekstseksjon/timeline/timeline'; import { TagContainer, TemplateSectionTagList, @@ -19,18 +20,24 @@ import { useUpdateYtelseHjemmelIdListMutation, } from '@app/redux-api/maltekstseksjoner/mutations'; import { IGetMaltekstseksjonParams } from '@app/types/maltekstseksjoner/params'; -import { IDraftMaltekstseksjon } from '@app/types/maltekstseksjoner/responses'; +import { IDraftMaltekstseksjon, IMaltekstseksjon } from '@app/types/maltekstseksjoner/responses'; import { Container, DateTimeContainer, Header, MetadataContainer } from '../common'; import { Actions } from './actions'; import { Sidebar } from './sidebar'; interface MaltekstProps { maltekstseksjon: IDraftMaltekstseksjon; + nextMaltekstseksjon?: IMaltekstseksjon; query: IGetMaltekstseksjonParams; onDraftDeleted: () => void; } -export const DraftMaltekstSection = ({ maltekstseksjon, query, onDraftDeleted }: MaltekstProps) => { +export const DraftMaltekstSection = ({ + maltekstseksjon, + nextMaltekstseksjon, + query, + onDraftDeleted, +}: MaltekstProps) => { const { id, title } = maltekstseksjon; const [updateTitle, { isLoading: isUpdatingTitle }] = useUpdateMaltekstTitleMutation({ fixedCacheKey: `${maltekstseksjon.id}-title`, @@ -65,6 +72,10 @@ export const DraftMaltekstSection = ({ maltekstseksjon, query, onDraftDeleted }: av {lastEditor === undefined ? 'Ukjent' : } + @@ -77,7 +88,7 @@ export const DraftMaltekstSection = ({ maltekstseksjon, query, onDraftDeleted }: - + ); }; diff --git a/frontend/src/components/maltekstseksjoner/maltekstseksjon/list-item.tsx b/frontend/src/components/maltekstseksjoner/maltekstseksjon/list-item.tsx index 993c607f3..f75ab6503 100644 --- a/frontend/src/components/maltekstseksjoner/maltekstseksjon/list-item.tsx +++ b/frontend/src/components/maltekstseksjoner/maltekstseksjon/list-item.tsx @@ -10,6 +10,7 @@ import { useGetTextVersionsQuery } from '@app/redux-api/texts/queries'; import { IGetMaltekstseksjonParams, RichTextTypes } from '@app/types/common-text-types'; import { isApiError } from '@app/types/errors'; import { IMaltekstseksjon } from '@app/types/maltekstseksjoner/responses'; +import { IText } from '@app/types/texts/responses'; import { DragAndDropContext } from '../drag-and-drop/drag-context'; import { TextLink } from '../text-link'; @@ -43,7 +44,10 @@ export const LoadTextListItem = ({ textId, maltekstseksjon, query }: LoadTextLis [setDraggedTextId, textId], ); - const text = !isLoading && versions !== undefined ? versions[0] : undefined; + const text = + !isLoading && versions !== undefined + ? getFirstText(versions, maltekstseksjon.publishedDateTime !== null) + : undefined; const isReady = text !== undefined; @@ -145,3 +149,11 @@ const HelpTextContainer = styled.div` max-width: 300px; white-space: normal; `; + +const getFirstText = (versions: IText[], isPublished: boolean) => { + if (isPublished) { + return versions.at(0); + } + + return versions.find((v) => v.published); +}; diff --git a/frontend/src/components/maltekstseksjoner/maltekstseksjon/maltekstseksjon-published.tsx b/frontend/src/components/maltekstseksjoner/maltekstseksjon/maltekstseksjon-published.tsx index 5a723cc8d..03cd0e8a5 100644 --- a/frontend/src/components/maltekstseksjoner/maltekstseksjon/maltekstseksjon-published.tsx +++ b/frontend/src/components/maltekstseksjoner/maltekstseksjon/maltekstseksjon-published.tsx @@ -7,6 +7,7 @@ import { DateTime } from '@app/components/datetime/datetime'; import { getTitle } from '@app/components/editable-title/editable-title'; import { EditorName } from '@app/components/editor-name/editor-name'; import { MaltekstseksjonTexts } from '@app/components/maltekstseksjoner/maltekstseksjon/texts'; +import { MaltekstHistoryModal } from '@app/components/maltekstseksjoner/maltekstseksjon/timeline/timeline'; import { TagContainer, TemplateSectionTagList, @@ -16,7 +17,7 @@ import { import { TextHistory } from '@app/components/text-history/text-history'; import { useCreateDraftFromVersionMutation } from '@app/redux-api/maltekstseksjoner/mutations'; import { IGetMaltekstseksjonParams } from '@app/types/maltekstseksjoner/params'; -import { IPublishedMaltekstseksjon } from '@app/types/maltekstseksjoner/responses'; +import { IMaltekstseksjon, IPublishedMaltekstseksjon } from '@app/types/maltekstseksjoner/responses'; import { TextListItem } from '../styled-components'; import { ActionsContainer, @@ -31,11 +32,17 @@ import { LoadTextListItem } from './list-item'; interface MaltekstProps { maltekstseksjon: IPublishedMaltekstseksjon; + nextMaltekstseksjon?: IMaltekstseksjon; query: IGetMaltekstseksjonParams; onDraftCreated: (versionId: string) => void; } -export const PublishedMaltekstSection = ({ maltekstseksjon, query, onDraftCreated }: MaltekstProps) => { +export const PublishedMaltekstSection = ({ + maltekstseksjon, + nextMaltekstseksjon, + query, + onDraftCreated, +}: MaltekstProps) => { const { textId: activeTextId } = useParams<{ textId: string }>(); const { id, title, textIdList, publishedDateTime, versionId, publishedBy } = maltekstseksjon; const [createDraft, { isLoading }] = useCreateDraftFromVersionMutation(); @@ -60,6 +67,10 @@ export const PublishedMaltekstSection = ({ maltekstseksjon, query, onDraftCreate av {publishedBy === null ? 'Ukjent' : } + @@ -83,7 +94,7 @@ export const PublishedMaltekstSection = ({ maltekstseksjon, query, onDraftCreate - + ); }; diff --git a/frontend/src/components/maltekstseksjoner/maltekstseksjon/maltekstseksjon-versions.tsx b/frontend/src/components/maltekstseksjoner/maltekstseksjon/maltekstseksjon-versions.tsx index dc5df656d..50521b65e 100644 --- a/frontend/src/components/maltekstseksjoner/maltekstseksjon/maltekstseksjon-versions.tsx +++ b/frontend/src/components/maltekstseksjoner/maltekstseksjon/maltekstseksjon-versions.tsx @@ -14,12 +14,12 @@ import { DraftMaltekstSection } from './draft/draft'; import { PublishedMaltekstSection } from './maltekstseksjon-published'; interface Props { - id: string; + maltekstseksjon: IMaltekstseksjon; query: IGetMaltekstseksjonParams; } -export const MaltekstseksjonVersions = ({ id, query }: Props) => { - const { data: versions, isLoading } = useGetMaltekstseksjonVersionsQuery(id); +export const MaltekstseksjonVersions = ({ maltekstseksjon, query }: Props) => { + const { data: versions, isLoading } = useGetMaltekstseksjonVersionsQuery(maltekstseksjon.id); if (isLoading || versions === undefined) { return null; @@ -81,14 +81,24 @@ const Loaded = ({ versions, first, query }: LoadedProps) => { selectedTabId={maltekstseksjonVersionId} setSelectedTabId={setSelectedTabId} versions={versions} - createDraftPanel={(version) => ( + createDraftPanel={(version, nextVersion) => ( - + )} - createPublishedPanel={(version) => ( + createPublishedPanel={(version, nextVersion) => ( - + )} /> diff --git a/frontend/src/components/maltekstseksjoner/maltekstseksjon/maltekstseksjon.tsx b/frontend/src/components/maltekstseksjoner/maltekstseksjon/maltekstseksjon.tsx index 350594d12..7f759b380 100644 --- a/frontend/src/components/maltekstseksjoner/maltekstseksjon/maltekstseksjon.tsx +++ b/frontend/src/components/maltekstseksjoner/maltekstseksjon/maltekstseksjon.tsx @@ -16,7 +16,7 @@ export const Maltekstseksjon = ({ maltekstseksjon, query }: Props) => ( - + ); diff --git a/frontend/src/components/maltekstseksjoner/maltekstseksjon/preview.tsx b/frontend/src/components/maltekstseksjoner/maltekstseksjon/preview.tsx index 9efb6f7ee..2a19cd259 100644 --- a/frontend/src/components/maltekstseksjoner/maltekstseksjon/preview.tsx +++ b/frontend/src/components/maltekstseksjoner/maltekstseksjon/preview.tsx @@ -2,25 +2,27 @@ import { Alert, Loader } from '@navikt/ds-react'; import React, { useEffect, useState } from 'react'; import { styled } from 'styled-components'; import { RedaktoerRichText } from '@app/components/redaktoer-rich-text/redaktoer-rich-text'; +import { isNotUndefined } from '@app/functions/is-not-type-guards'; import { isRichText } from '@app/functions/is-rich-plain-text'; import { useRedaktoerLanguage } from '@app/hooks/use-redaktoer-language'; import { SPELL_CHECK_LANGUAGES } from '@app/hooks/use-smart-editor-language'; import { EditorValue } from '@app/plate/types'; import { useUpdateTextIdListMutation } from '@app/redux-api/maltekstseksjoner/mutations'; -import { useLazyGetTextByIdQuery } from '@app/redux-api/texts/queries'; +import { useLazyGetTextVersionsQuery } from '@app/redux-api/texts/queries'; import { isApiError } from '@app/types/errors'; import { IMaltekstseksjon } from '@app/types/maltekstseksjoner/responses'; import { IRichText } from '@app/types/texts/responses'; interface Props { maltekstseksjon: IMaltekstseksjon; + nextMaltekstseksjon?: IMaltekstseksjon; } -export const MaltekstseksjonPreview = ({ maltekstseksjon }: Props) => { +export const MaltekstseksjonPreview = ({ maltekstseksjon, nextMaltekstseksjon }: Props) => { const [, { isLoading: isUpdating }] = useUpdateTextIdListMutation({ fixedCacheKey: maltekstseksjon.id }); const [texts, setTexts] = useState([]); const [error, setError] = useState(null); - const [getText] = useLazyGetTextByIdQuery(); + const [getTextVersions] = useLazyGetTextVersionsQuery(); const { textIdList } = maltekstseksjon; const lang = useRedaktoerLanguage(); const editorId = `${textIdList.join(':')}-${lang}-preview`; @@ -28,12 +30,29 @@ export const MaltekstseksjonPreview = ({ maltekstseksjon }: Props) => { useEffect(() => { setError(null); - const promises = textIdList.map((textId) => getText(textId, true).unwrap()); + const promises = textIdList.map((textId) => getTextVersions(textId, true).unwrap()); Promise.all(promises) + .then((textVersionsList) => + textVersionsList + .map((textVersions) => + textVersions.find((v) => { + if (maltekstseksjon.publishedDateTime === null || nextMaltekstseksjon === undefined) { + return true; + } + + if (nextMaltekstseksjon.publishedDateTime === null) { + return v.publishedDateTime !== null; + } + + return v.publishedDateTime !== null && v.publishedDateTime < nextMaltekstseksjon.publishedDateTime; + }), + ) + .filter(isNotUndefined), + ) .then((textList) => setTexts(textList.filter(isRichText))) .catch((e) => setError('data' in e && isApiError(e.data) ? e.data.detail : e.message)); - }, [getText, textIdList]); + }, [getTextVersions, maltekstseksjon.published, maltekstseksjon.publishedDateTime, nextMaltekstseksjon, textIdList]); if (error !== null) { return ( diff --git a/frontend/src/components/maltekstseksjoner/maltekstseksjon/texts.tsx b/frontend/src/components/maltekstseksjoner/maltekstseksjon/texts.tsx index e49b88c19..6d7c84302 100644 --- a/frontend/src/components/maltekstseksjoner/maltekstseksjon/texts.tsx +++ b/frontend/src/components/maltekstseksjoner/maltekstseksjon/texts.tsx @@ -9,6 +9,7 @@ import { IMaltekstseksjon } from '@app/types/maltekstseksjoner/responses'; interface Props { maltekstseksjon: IMaltekstseksjon; + nextMaltekstseksjon?: IMaltekstseksjon; query: IGetMaltekstseksjonParams; } @@ -17,7 +18,7 @@ enum TabsEnum { EDIT = 'EDIT', } -export const MaltekstseksjonTexts = ({ maltekstseksjon, query }: Props) => { +export const MaltekstseksjonTexts = ({ maltekstseksjon, nextMaltekstseksjon, query }: Props) => { const [activeTab, setActiveTab] = useState(TabsEnum.PREVIEW); const textCount = maltekstseksjon.textIdList.length; const [previousTextCount, setPreviousTextCount] = useState(textCount); @@ -41,10 +42,10 @@ export const MaltekstseksjonTexts = ({ maltekstseksjon, query }: Props) => { } /> - + - + ); diff --git a/frontend/src/components/maltekstseksjoner/maltekstseksjon/timeline/pin-time.tsx b/frontend/src/components/maltekstseksjoner/maltekstseksjon/timeline/pin-time.tsx new file mode 100644 index 000000000..ce04e0380 --- /dev/null +++ b/frontend/src/components/maltekstseksjoner/maltekstseksjon/timeline/pin-time.tsx @@ -0,0 +1,51 @@ +import { format } from 'date-fns'; +import React from 'react'; +import { styled } from 'styled-components'; +import { ISO_DATETIME_FORMAT } from '@app/components/date-picker/constants'; +import { Time } from '@app/components/time/time'; + +interface Props { + from: Date; + to: Date; + value: Date; + onChange: (value: Date) => void; +} + +export const PinTime = ({ from, to, value, onChange }: Props) => { + const minTime = from.getTime(); + const maxTime = to.getTime(); + const length = maxTime - minTime; + const min = 0; + const max = length; + const step = length / 1_000; + + return ( + + + ); +}; + +const Container = styled.div` + display: flex; + flex-direction: row; + gap: 8px; +`; + +const RangeInput = styled.input` + flex-grow: 1; +`; diff --git a/frontend/src/components/maltekstseksjoner/maltekstseksjon/timeline/timeline.tsx b/frontend/src/components/maltekstseksjoner/maltekstseksjon/timeline/timeline.tsx new file mode 100644 index 000000000..6014a8678 --- /dev/null +++ b/frontend/src/components/maltekstseksjoner/maltekstseksjon/timeline/timeline.tsx @@ -0,0 +1,206 @@ +/* eslint-disable max-lines */ +import { Alert, Button, DatePicker, Modal, Tag, Timeline } from '@navikt/ds-react'; +import { format, parseISO } from 'date-fns'; +import React, { useCallback, useMemo, useRef, useState } from 'react'; +import { styled } from 'styled-components'; +import { PRETTY_DATETIME_FORMAT } from '@app/components/date-picker/constants'; +import { PinTime } from '@app/components/maltekstseksjoner/maltekstseksjon/timeline/pin-time'; +import { useTimelineData } from '@app/components/maltekstseksjoner/maltekstseksjon/timeline/use-timeline'; +import { RedaktoerRichText } from '@app/components/redaktoer-rich-text/redaktoer-rich-text'; +import { isoDateTimeToPretty } from '@app/domain/date'; +import { omit } from '@app/functions/omit'; +import { useRedaktoerLanguage } from '@app/hooks/use-redaktoer-language'; +import { SPELL_CHECK_LANGUAGES } from '@app/hooks/use-smart-editor-language'; +import { IMaltekstseksjon } from '@app/types/maltekstseksjoner/responses'; + +interface DateRange { + from: Date | undefined; + to?: Date | undefined; +} + +interface MaltekstHistoryProps { + maltekstseksjon: IMaltekstseksjon; + nextMaltekstseksjonDate?: string | null; +} + +export const MaltekstHistoryModal = (props: MaltekstHistoryProps) => { + const modalRef = useRef(null); + + return ( + <> + + + + + + + + ); +}; + +const MaltekstHistory = ({ maltekstseksjon, nextMaltekstseksjonDate }: MaltekstHistoryProps) => { + const lang = useRedaktoerLanguage(); + const [pinDate, setPinDate] = useState(new Date()); + + const hasNextMaltekstseksjon = typeof nextMaltekstseksjonDate === 'string'; + + const initialStartDate = useMemo( + () => + maltekstseksjon.publishedDateTime === null + ? parseISO(maltekstseksjon.created) + : parseISO(maltekstseksjon.publishedDateTime), + [maltekstseksjon.created, maltekstseksjon.publishedDateTime], + ); + + const [startDate, setStartDate] = useState(initialStartDate); + + const initialEndDate = useMemo( + () => (hasNextMaltekstseksjon ? parseISO(nextMaltekstseksjonDate) : undefined), + [hasNextMaltekstseksjon, nextMaltekstseksjonDate], + ); + + const [endDate, setEndDate] = useState(initialEndDate); + + const data = useTimelineData(maltekstseksjon, setPinDate); + + const onSelectRange = useCallback( + (range?: DateRange) => { + if (range === undefined) { + setStartDate(initialStartDate); + setEndDate(initialEndDate); + } else { + const { from = initialStartDate, to = initialEndDate } = range; + setStartDate(from); + setEndDate(to); + } + }, + [initialEndDate, initialStartDate], + ); + + const description = useMemo(() => { + if (maltekstseksjon.publishedDateTime === null) { + return 'Denne versjonen er et utkast.'; + } + + const publishedDate = isoDateTimeToPretty(maltekstseksjon.publishedDateTime); + + if (!hasNextMaltekstseksjon) { + return ( + <> + Denne versjonen ble publisert {publishedDate} og er den siste publiserte{' '} + versjonen. + + ); + } + + const nextPublishedDate = isoDateTimeToPretty(nextMaltekstseksjonDate); + + return ( + <> + Denne versjonen ble publisert {publishedDate} og ble erstattet av en ny maltekstseksjon + publisert {nextPublishedDate}. + + ); + }, [hasNextMaltekstseksjon, maltekstseksjon.publishedDateTime, nextMaltekstseksjonDate]); + + const maxDate = useMemo(() => initialEndDate ?? new Date(), [initialEndDate]); + const toDate = useMemo(() => endDate ?? new Date(), [endDate]); + + const activePeriods = useMemo(() => { + if (data === null) { + return []; + } + + return data.rows.flatMap((row) => row.periods).filter((period) => period.start <= pinDate && period.end >= pinDate); + }, [data, pinDate]); + + if (data === null) { + return null; + } + + return ( + + + {description} + + + + + {data.rows.map(({ periods, ...row }) => ( + + {periods.map((period) => ( + + ))} + + ))} + + {data.allRow.periods.map((period) => ( + + ))} + + + + + {data.allRow.periods.toReversed().map((period) => ( +
  • + +
  • + ))} +
    + + setPinDate(v)} value={pinDate} /> + +
    + + {format(startDate, PRETTY_DATETIME_FORMAT)} - {format(toDate, PRETTY_DATETIME_FORMAT)} + +
    + + + + p.key).join('-')} + savedContent={activePeriods.flatMap((p) => p.richText[lang] ?? [])} + readOnly + lang={SPELL_CHECK_LANGUAGES[lang]} + /> + +
    + ); +}; + +const StyledTimeline = styled(Timeline)` + min-width: 800px; +`; + +const Container = styled.div` + display: flex; + flex-direction: column; + gap: 16px; +`; + +const RowContainer = styled.div` + display: flex; + flex-direction: row; + gap: 8px; +`; + +const List = styled.ul` + display: flex; + flex-direction: row; + padding: 0; + margin: 0; + list-style: none; + width: 100%; + overflow: auto; +`; diff --git a/frontend/src/components/maltekstseksjoner/maltekstseksjon/timeline/use-timeline.tsx b/frontend/src/components/maltekstseksjoner/maltekstseksjon/timeline/use-timeline.tsx new file mode 100644 index 000000000..76ff75318 --- /dev/null +++ b/frontend/src/components/maltekstseksjoner/maltekstseksjon/timeline/use-timeline.tsx @@ -0,0 +1,183 @@ +import { PauseIcon, PencilIcon, PlayIcon } from '@navikt/aksel-icons'; +import { Heading, TimelinePeriodProps, TimelineRowProps } from '@navikt/ds-react'; +import { compareAsc, max, min, parseISO } from 'date-fns'; +import React, { useEffect, useState } from 'react'; +import { styled } from 'styled-components'; +import { RedaktoerRichText } from '@app/components/redaktoer-rich-text/redaktoer-rich-text'; +import { Time } from '@app/components/time/time'; +import { isNotNull } from '@app/functions/is-not-type-guards'; +import { isRichText } from '@app/functions/is-rich-plain-text'; +import { useRedaktoerLanguage } from '@app/hooks/use-redaktoer-language'; +import { SPELL_CHECK_LANGUAGES } from '@app/hooks/use-smart-editor-language'; +import { useLazyGetTextVersionsQuery } from '@app/redux-api/texts/queries'; +import { IMaltekstseksjon } from '@app/types/maltekstseksjoner/responses'; +import { IRichText, IText } from '@app/types/texts/responses'; + +interface PeriodData extends TimelinePeriodProps { + key: string; + richText: IRichText['richText']; +} + +interface RowData extends TimelineRowProps { + key: string; + periods: PeriodData[]; +} + +interface TimelineData { + rows: RowData[]; + allRow: RowData; +} + +export const useTimelineData = ( + maltekstseksjon: IMaltekstseksjon, + setPinDate: (pinDate: Date) => void, +): TimelineData | null => { + const [timeline, setTimeline] = useState(null); + const [getTextVersions] = useLazyGetTextVersionsQuery(); + + useEffect(() => { + const allTextsPromises = maltekstseksjon.textIdList.map((textId) => getTextVersions(textId).unwrap()); + + Promise.all(allTextsPromises).then((allTextsVersions) => { + const textRows: RowData[] = allTextsVersions + .map((textVersions) => { + const reversedTextVersions = textVersions.filter(isRichText).toReversed(); + const [firstTextVersion] = reversedTextVersions; + + if (firstTextVersion === undefined) { + return null; + } + + return { + key: firstTextVersion.id, + label: firstTextVersion.title, + periods: reversedTextVersions.map((t, i) => { + const next = reversedTextVersions[i + 1]; + + const created = parseISO(t.created); + const published = t.publishedDateTime === null ? null : parseISO(t.publishedDateTime); + const nextStart = + next === undefined || next.publishedDateTime === null ? null : parseISO(next.publishedDateTime); + + const { status, icon } = getStatusAndIcon(t); + + return { + key: t.versionId, + richText: t.richText, + start: published ?? created, + end: nextStart ?? new Date(), + status, + icon, + statusLabel: t.title, + children: , + }; + }), + }; + }) + .filter(isNotNull); + + const allPeriods = textRows.flatMap((row) => row.periods).toSorted((a, b) => compareAsc(a.start, b.start)); + const lastIndex = allPeriods.length - 1; + + const allRow: RowData = { + key: maltekstseksjon.id, + label: maltekstseksjon.title, + periods: allPeriods.map((p, i) => { + const next = allPeriods[i + 1]; + const end = next === undefined ? p.end : min([p.end, next.start]); + const isPublished = maltekstseksjon.published && i === lastIndex; + + return { + key: `all-${p.key}`, + richText: p.richText, + start: p.start, + end, + status: isPublished ? 'info' : 'neutral', + icon: isPublished ? : , + onSelectPeriod: () => setPinDate(max([p.start, maltekstseksjon.publishedDateTime ?? new Date()])), + }; + }), + }; + + setTimeline({ rows: textRows, allRow }); + }); + }, [getTextVersions, maltekstseksjon, setPinDate]); + + return timeline; +}; + +interface StatusAndIcon { + status: TimelinePeriodProps['status']; + icon: React.ReactNode; +} + +const getStatusAndIcon = (version: IMaltekstseksjon | IText): StatusAndIcon => { + if (version.published) { + return { status: 'info', icon: }; + } + + if (version.publishedDateTime === null) { + return { status: 'warning', icon: }; + } + + return { status: 'neutral', icon: }; +}; + +interface PeriodContentProps extends TimeSpanProps { + text: IText; +} + +const PeriodContent = ({ text, ...timeSpanProps }: PeriodContentProps) => { + const lang = useRedaktoerLanguage(); + + return ( +
    + + {text.title} + + + {isRichText(text) ? ( + + ) : null} +
    + ); +}; + +interface TimeSpanProps { + created: Date; + published: Date | null; + end: Date | null; +} + +const TimeSpan = ({ created, published, end }: TimeSpanProps) => { + if (published === null) { + return ( + + Utkast opprettet . + + ); + } + + if (end === null) { + return ( + + Nåværende aktive versjon. Publisert . + + ); + } + + return ( + + Tidligere aktiv version fra til . + + ); +}; + +const StyledTime = styled(Time)` + font-weight: bold; +`; diff --git a/frontend/src/components/maltekstseksjoner/texts/published-rich-text.tsx b/frontend/src/components/maltekstseksjoner/texts/published-rich-text.tsx index 550be5272..146d9abe1 100644 --- a/frontend/src/components/maltekstseksjoner/texts/published-rich-text.tsx +++ b/frontend/src/components/maltekstseksjoner/texts/published-rich-text.tsx @@ -52,7 +52,7 @@ export const PublishedRichText = ({ text, onDraftCreated, maltekstseksjonId, has <> { const changed: RichTexts = { ...richTexts, [language]: t }; diff --git a/frontend/src/components/maltekstseksjoner/texts/text-draft/text-draft.tsx b/frontend/src/components/maltekstseksjoner/texts/text-draft/text-draft.tsx index 549611eb1..194247989 100644 --- a/frontend/src/components/maltekstseksjoner/texts/text-draft/text-draft.tsx +++ b/frontend/src/components/maltekstseksjoner/texts/text-draft/text-draft.tsx @@ -15,6 +15,7 @@ import { useUpdateRichTextMutation, } from '@app/redux-api/texts/mutations'; import { RichTextTypes } from '@app/types/common-text-types'; +import { IMaltekstseksjon } from '@app/types/maltekstseksjoner/responses'; import { LANGUAGES, Language, isLanguage } from '@app/types/texts/language'; import { IDraftRichText } from '@app/types/texts/responses'; import { areDescendantsEqual } from '../../../../functions/are-descendants-equal'; @@ -26,19 +27,19 @@ interface Props { setActive: (textId: string) => void; isDeletable: boolean; onDraftDeleted: () => void; - maltekstseksjonId: string; + maltekstseksjon: IMaltekstseksjon; } -export const DraftText = ({ text, isActive, setActive, ...rest }: Props) => { +export const DraftText = ({ text, isActive, setActive, maltekstseksjon, ...rest }: Props) => { const [updateTextType, { isLoading: isTextTypeUpdating }] = useSetTextTypeMutation(); const [updateTitle, { isLoading: isTitleUpdating }] = useSetTextTitleMutation(); const [updateRichText, richTextStatus] = useUpdateRichTextMutation(); - const [publish] = usePublishMutation({ fixedCacheKey: text.id }); + const { id } = text; + const [publish] = usePublishMutation({ fixedCacheKey: id }); const containerRef = useRef(null); const editorRef = useRef(null); const language = useRedaktoerLanguage(); const [richTexts, setRichTexts] = useState(text.richText); - const { id } = text; const isUpdating = useRef(false); const richTextRef = useRef(richTexts); const queryRef = useRef({ textType: text.textType }); @@ -189,6 +190,7 @@ export const DraftText = ({ text, isActive, setActive, ...rest }: Props) => { isSaving={richTextStatus.isLoading || isTextTypeUpdating || isTitleUpdating} onPublish={onPublish} error={error} + maltekstseksjonId={maltekstseksjon.id} {...rest} /> diff --git a/frontend/src/components/maltekstseksjoner/texts/text-list.tsx b/frontend/src/components/maltekstseksjoner/texts/text-list.tsx index 5637df0ed..b8b75eb29 100644 --- a/frontend/src/components/maltekstseksjoner/texts/text-list.tsx +++ b/frontend/src/components/maltekstseksjoner/texts/text-list.tsx @@ -12,10 +12,11 @@ import { TextVersions } from './text-versions'; interface Props { maltekstseksjon: IMaltekstseksjon; + nextMaltekstseksjon?: IMaltekstseksjon; query: IGetMaltekstseksjonParams; } -export const TextList = ({ maltekstseksjon, query }: Props) => { +export const TextList = ({ maltekstseksjon, nextMaltekstseksjon, query }: Props) => { const setPath = useNavigateMaltekstseksjoner(); const { textId: activeTextId } = useParams(); const [updateMaltekst, { isLoading: isMaltekstUpdating }] = useUpdateTextIdListMutation({ @@ -143,7 +144,8 @@ export const TextList = ({ maltekstseksjon, query }: Props) => { textId={textId} isActive={textId === activeTextId} setActive={setActive} - maltekstseksjonId={maltekstseksjon.id} + maltekstseksjon={maltekstseksjon} + nextMaltekstseksjon={nextMaltekstseksjon} /> ))} diff --git a/frontend/src/components/maltekstseksjoner/texts/text-versions.tsx b/frontend/src/components/maltekstseksjoner/texts/text-versions.tsx index 57dd80b09..75014db45 100644 --- a/frontend/src/components/maltekstseksjoner/texts/text-versions.tsx +++ b/frontend/src/components/maltekstseksjoner/texts/text-versions.tsx @@ -4,6 +4,7 @@ import { VersionTabs } from '@app/components/versioned-tabs/versioned-tabs'; import { useGetTextVersionsQuery } from '@app/redux-api/texts/queries'; import { RichTextTypes } from '@app/types/common-text-types'; import { isApiError } from '@app/types/errors'; +import { IMaltekstseksjon } from '@app/types/maltekstseksjoner/responses'; import { IDraftRichText, IPublishedRichText, IRichText, IText } from '@app/types/texts/responses'; import { PublishedRichText } from './published-rich-text'; import { DraftText } from './text-draft/text-draft'; @@ -12,11 +13,12 @@ interface Props { textId: string; isActive: boolean; setActive: (textId: string) => void; - maltekstseksjonId: string; + maltekstseksjon: IMaltekstseksjon; + nextMaltekstseksjon?: IMaltekstseksjon; className?: string; } -export const TextVersions = ({ textId, className, ...rest }: Props) => { +export const TextVersions = ({ textId, className, maltekstseksjon, nextMaltekstseksjon, ...rest }: Props) => { const { data: versions, isLoading: versionsIsLoading, isError, error } = useGetTextVersionsQuery(textId); if (isError) { @@ -37,7 +39,17 @@ export const TextVersions = ({ textId, className, ...rest }: Props) => { ); } - const validVersions = versions.filter(isValidType); + const validVersions = versions.filter(isValidType).filter((v) => { + if (maltekstseksjon.publishedDateTime === null || nextMaltekstseksjon === undefined) { + return true; + } + + if (nextMaltekstseksjon.publishedDateTime === null) { + return v.publishedDateTime !== null; + } + + return v.publishedDateTime !== null && v.publishedDateTime <= nextMaltekstseksjon.publishedDateTime; + }); const [firstVersion] = validVersions; @@ -46,7 +58,14 @@ export const TextVersions = ({ textId, className, ...rest }: Props) => { } return ( - + ); }; @@ -58,7 +77,7 @@ interface LoadedProps extends Props { versions: IRichText[]; } -const Loaded = ({ firstVersion, versions, isActive, className, ...props }: LoadedProps) => { +const Loaded = ({ firstVersion, versions, isActive, className, maltekstseksjon, ...props }: LoadedProps) => { const tabsContainerRef = useRef(null); const [tabId, setTabId] = useState(firstVersion.versionId); @@ -96,6 +115,7 @@ const Loaded = ({ firstVersion, versions, isActive, className, ...props }: Loade createDraftPanel={(version) => ( ( ) : null} {editors.map((editor) => ( - + Endret diff --git a/frontend/src/components/time/time.tsx b/frontend/src/components/time/time.tsx new file mode 100644 index 000000000..ed3d41f30 --- /dev/null +++ b/frontend/src/components/time/time.tsx @@ -0,0 +1,13 @@ +import { format } from 'date-fns'; +import React from 'react'; +import { ISO_DATETIME_FORMAT, PRETTY_DATETIME_FORMAT } from '@app/components/date-picker/constants'; + +interface Props { + date: Date; + className?: string; +} +export const Time = ({ date, className }: Props) => ( + +); diff --git a/frontend/src/components/versioned-tabs/tab-label.tsx b/frontend/src/components/versioned-tabs/tab-label.tsx index 23250464b..0adf5935b 100644 --- a/frontend/src/components/versioned-tabs/tab-label.tsx +++ b/frontend/src/components/versioned-tabs/tab-label.tsx @@ -1,21 +1,22 @@ import { Tag } from '@navikt/ds-react'; import React from 'react'; import { styled } from 'styled-components'; +import { isoDateTimeToPretty } from '@app/domain/date'; interface TabLabelProps { isDraft: boolean; isPublished: boolean; - children: string | number; + date: string; } -export const TabLabel = ({ isDraft, isPublished, children }: TabLabelProps) => { +export const TabLabel = ({ isDraft, isPublished, date }: TabLabelProps) => { if (isPublished) { return ( Aktiv - Versjon {children} + {isoDateTimeToPretty(date)} ); } @@ -26,7 +27,7 @@ export const TabLabel = ({ isDraft, isPublished, children }: TabLabelProps) => { Utkast - Versjon {children} + {isoDateTimeToPretty(date)} ); } @@ -36,7 +37,7 @@ export const TabLabel = ({ isDraft, isPublished, children }: TabLabelProps) => { Inaktiv - Versjon {children} + {isoDateTimeToPretty(date)} ); }; diff --git a/frontend/src/components/versioned-tabs/versioned-tabs.tsx b/frontend/src/components/versioned-tabs/versioned-tabs.tsx index 7348e96be..270aab60e 100644 --- a/frontend/src/components/versioned-tabs/versioned-tabs.tsx +++ b/frontend/src/components/versioned-tabs/versioned-tabs.tsx @@ -8,12 +8,14 @@ interface DraftVersion { versionId: string; published: false; publishedDateTime: null; + modified: string; } interface PublishedVersion { versionId: string; published: boolean; publishedDateTime: string; + modified: string; } interface Props { @@ -21,8 +23,8 @@ interface Props { versions: (D | P)[]; selectedTabId: string | undefined; setSelectedTabId: (tabId: string, replace?: boolean) => void; - createDraftPanel: (version: D) => React.ReactNode; - createPublishedPanel: (version: P) => React.ReactNode; + createDraftPanel: (version: D, nextVersion?: D | P) => React.ReactNode; + createPublishedPanel: (version: P, nextVersion?: D | P) => React.ReactNode; className?: string; setRef?: (ref: HTMLDivElement | null) => void; } @@ -48,20 +50,16 @@ export const VersionTabs = ( continue; } - const { versionId, published, publishedDateTime } = version; + const { versionId, published, publishedDateTime, modified } = version; const isDraft = publishedDateTime === null; - const label = ( - - {length - i} - - ); + const label = ; tabs[i] = ; panels[i] = ( - {isDraft ? createDraftPanel(version) : createPublishedPanel(version)} + {isDraft ? createDraftPanel(version, versions[i - 1]) : createPublishedPanel(version, versions[i - 1])} ); } diff --git a/frontend/src/functions/omit.ts b/frontend/src/functions/omit.ts new file mode 100644 index 000000000..23bea6134 --- /dev/null +++ b/frontend/src/functions/omit.ts @@ -0,0 +1,9 @@ +export const omit = (obj: T, ...props: K[]): Omit => { + const newObj = { ...obj }; + + for (const prop of props) { + delete newObj[prop]; + } + + return newObj; +}; diff --git a/frontend/src/plate/components/placeholder/helpers.ts b/frontend/src/plate/components/placeholder/helpers.ts index 4ce4fec9c..18aecb285 100644 --- a/frontend/src/plate/components/placeholder/helpers.ts +++ b/frontend/src/plate/components/placeholder/helpers.ts @@ -114,6 +114,12 @@ const getMaltekstElement = (editor: PlateEditor, path: Path | undef return undefined; } + if (!editor.hasPath(path)) { + console.warn('Path not found in editor', editor.children, path); + + return undefined; + } + const ancestors = getNodeAncestors(editor, path); for (const [node] of ancestors) { diff --git a/frontend/src/redux-api/texts/queries.ts b/frontend/src/redux-api/texts/queries.ts index dcbb618cc..3de280424 100644 --- a/frontend/src/redux-api/texts/queries.ts +++ b/frontend/src/redux-api/texts/queries.ts @@ -29,5 +29,10 @@ export const textsQuerySlice = textsApi.injectEndpoints({ }), }); -export const { useGetTextByIdQuery, useGetTextVersionsQuery, useGetTextsQuery, useLazyGetTextByIdQuery } = - textsQuerySlice; +export const { + useGetTextByIdQuery, + useGetTextVersionsQuery, + useGetTextsQuery, + useLazyGetTextByIdQuery, + useLazyGetTextVersionsQuery, +} = textsQuerySlice;