diff --git a/.tool-versions b/.tool-versions index 60a79e65f..eece629d5 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1 +1 @@ -bun 1.1.19 +bun 1.1.27 diff --git a/Dockerfile b/Dockerfile index 108040766..da650f74f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ FROM node:22-alpine -ENV NODE_ENV production +ENV NODE_ENV=production ENV NPM_CONFIG_CACHE=/tmp WORKDIR /usr/src/app @@ -10,7 +10,7 @@ COPY frontend frontend WORKDIR /usr/src/app/server ARG VERSION -ENV VERSION $VERSION +ENV VERSION=$VERSION -CMD node dist/server.js +CMD ["node", "dist/server.js"] EXPOSE 8080 diff --git a/frontend/bun.lockb b/frontend/bun.lockb index a7f5e05dd..2ef40859d 100755 Binary files a/frontend/bun.lockb and b/frontend/bun.lockb differ diff --git a/frontend/package.json b/frontend/package.json index 7dccf8e63..b0dea1c0e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,66 +11,72 @@ }, "license": "MIT", "devDependencies": { - "@happy-dom/global-registrator": "^14.12.3", - "@types/bun": "^1.1.6", - "@types/react": "18.3.3", + "@happy-dom/global-registrator": "14.12.3", + "@types/bun": "1.1.10", + "@types/react": "18.3.10", "@types/react-dom": "18.3.0", - "@types/react-redux": "7.1.33", - "@typescript-eslint/eslint-plugin": "8.0.1", - "@typescript-eslint/parser": "8.0.1", - "@vitejs/plugin-react": "^4.3.1", + "@types/react-redux": "7.1.34", + "@typescript-eslint/eslint-plugin": "8.7.0", + "@typescript-eslint/parser": "8.7.0", + "@vitejs/plugin-react": "4.3.2", "css-loader": "7.1.2", "eslint": "8.57.0", "eslint-config-prettier": "9.1.0", - "eslint-import-resolver-typescript-bun": "^0.0.101", - "eslint-plugin-import": "2.29.1", - "eslint-plugin-jsx-a11y": "6.9.0", + "eslint-import-resolver-typescript-bun": "0.0.104", + "eslint-plugin-import": "2.30.0", + "eslint-plugin-jsx-a11y": "6.10.0", "eslint-plugin-prefer-arrow": "1.2.3", "eslint-plugin-prettier": "5.2.1", - "eslint-plugin-react": "7.35.0", + "eslint-plugin-react": "7.37.0", "eslint-plugin-react-hooks": "4.6.2", - "eslint-plugin-vitest": "^0.5.4", - "jsdom": "^24.1.1", + "eslint-plugin-vitest": "0.5.4", + "jsdom": "24.1.1", "prettier": "3.3.3", "style-loader": "4.0.0", "ts-loader": "9.5.1", - "typescript": "5.5.4", - "vite": "^5.4.0", - "vite-tsconfig-paths": "^5.0.0" + "typescript": "5.6.2", + "vite": "5.4.8", + "vite-tsconfig-paths": "5.0.1" }, "dependencies": { - "@grafana/faro-react": "^1.9.0", - "@grafana/faro-web-sdk": "^1.9.0", - "@grafana/faro-web-tracing": "^1.9.0", - "@navikt/aksel-icons": "6.14.0", - "@navikt/ds-css": "6.14.0", - "@navikt/ds-react": "6.14.0", + "@grafana/faro-react": "1.10.1", + "@grafana/faro-web-sdk": "1.10.1", + "@grafana/faro-web-tracing": "1.10.1", + "@hocuspocus/provider": "2.13.6", + "@navikt/aksel-icons": "7.1.0", + "@navikt/ds-css": "7.1.0", + "@navikt/ds-react": "7.1.0", "@navikt/fnrvalidator": "1.3.0", "@reduxjs/toolkit": "2.2.7", - "@styled-icons/fluentui-system-regular": "10.47.0", - "@types/qs": "^6.9.15", - "@udecode/plate-alignment": "36.0.11", - "@udecode/plate-autoformat": "36.0.0", - "@udecode/plate-basic-marks": "36.0.0", - "@udecode/plate-break": "36.0.0", - "@udecode/plate-common": "36.3.4", - "@udecode/plate-font": "36.0.0", - "@udecode/plate-heading": "36.0.12", - "@udecode/plate-indent": "36.0.0", - "@udecode/plate-list": "36.0.0", - "@udecode/plate-paragraph": "36.0.0", - "@udecode/plate-resizable": "36.0.0", - "@udecode/plate-serializer-docx": "36.3.5", - "@udecode/plate-table": "34.0.0", - "qs": "^6.13.0", + "@slate-yjs/core": "1.0.2", + "@slate-yjs/react": "1.1.0", + "@styled-icons/fluentui-system-regular": "10.47.0", + "@types/qs": "6.9.16", + "@udecode/plate-alignment": "38.0.1", + "@udecode/plate-autoformat": "38.0.1", + "@udecode/plate-basic-marks": "38.0.1", + "@udecode/plate-break": "38.0.1", + "@udecode/plate-common": "38.0.6", + "@udecode/plate-cursor": "38.0.0", + "@udecode/plate-font": "38.0.1", + "@udecode/plate-heading": "38.0.12", + "@udecode/plate-indent": "38.0.1", + "@udecode/plate-layout": "^38.0.1", + "@udecode/plate-list": "38.0.1", + "@udecode/plate-resizable": "38.0.0", + "@udecode/plate-serializer-docx": "36.5.8", + "@udecode/plate-table": "38.0.8", + "@udecode/plate-yjs": "38.0.1", + "qs": "6.13.0", "react": "18.3.1", "react-dom": "18.3.1", "react-redux": "9.1.2", - "react-router": "6.26.0", - "react-router-dom": "6.26.0", + "react-router": "6.26.2", + "react-router-dom": "6.26.2", "slate": "0.103.0", - "slate-history": "0.100.0", - "slate-react": "0.107.1", - "styled-components": "6.1.12" + "slate-history": "0.109.0", + "slate-react": "0.110.1", + "styled-components": "6.1.13", + "yjs": "13.6.19" } } diff --git a/frontend/src/bun-test-setup.ts b/frontend/src/bun-test-setup.ts index dc6b8793d..a35d8970d 100644 --- a/frontend/src/bun-test-setup.ts +++ b/frontend/src/bun-test-setup.ts @@ -30,4 +30,5 @@ mock.module('@app/static-data/static-data', () => ({ mock.module('@app/observability', () => ({ pushError: jest.fn(), + pushLog: jest.fn(), })); diff --git a/frontend/src/components/app/router.tsx b/frontend/src/components/app/router.tsx index 5fe5bcea0..9be6adcba 100644 --- a/frontend/src/components/app/router.tsx +++ b/frontend/src/components/app/router.tsx @@ -5,6 +5,7 @@ import { ModalEnum } from '@app/components/svarbrev/row/row'; import { AccessRightsPage } from '@app/pages/access-rights/access-rights'; import { AdminPage } from '@app/pages/admin/admin'; import { AnkebehandlingPage } from '@app/pages/ankebehandling/ankebehandling'; +import { BehandlingEtterTrOpphevetPage } from '@app/pages/behandling-etter-tr-opphevet/behandling-etter-tr-opphevet'; import { BunnteksterPage } from '@app/pages/bunntekster/bunntekster'; import { GodeFormuleringerPage } from '@app/pages/gode-formuleringer/gode-formuleringer'; import { KlagebehandlingPage } from '@app/pages/klagebehandling/klagebehandling'; @@ -33,6 +34,7 @@ export const Router = () => ( } /> } /> } /> + } /> }> diff --git a/frontend/src/components/behandling/behandling.tsx b/frontend/src/components/behandling/behandling.tsx index 652c7c68e..df5c4d1d7 100644 --- a/frontend/src/components/behandling/behandling.tsx +++ b/frontend/src/components/behandling/behandling.tsx @@ -1,4 +1,5 @@ import { Heading, Skeleton } from '@navikt/ds-react'; +import { BehandlingEtterTrOpphevetDetaljer } from '@app/components/behandling/behandlingsdetaljer/behandling-etter-tr-opphevet-detaljer'; import { BehandlingSection } from '@app/components/behandling/behandlingsdetaljer/behandling-section'; import { useOppgave } from '@app/hooks/oppgavebehandling/use-oppgave'; import { useBehandlingEnabled } from '@app/hooks/settings/use-setting'; @@ -67,13 +68,14 @@ const Behandlingsdetaljer = () => { ); } - if (oppgave.typeId === SaksTypeEnum.KLAGE) { - return ; + switch (oppgave.typeId) { + case SaksTypeEnum.KLAGE: + return ; + case SaksTypeEnum.ANKE: + return ; + case SaksTypeEnum.ANKE_I_TRYGDERETTEN: + return ; + case SaksTypeEnum.BEHANDLING_ETTER_TR_OPPHEVET: + return ; } - - if (oppgave.typeId === SaksTypeEnum.ANKE) { - return ; - } - - return ; }; diff --git a/frontend/src/components/behandling/behandlingsdetaljer/anke-mottatt-dato.tsx b/frontend/src/components/behandling/behandlingsdetaljer/anke-mottatt-dato.tsx index eec8988eb..fa3ef0120 100644 --- a/frontend/src/components/behandling/behandlingsdetaljer/anke-mottatt-dato.tsx +++ b/frontend/src/components/behandling/behandlingsdetaljer/anke-mottatt-dato.tsx @@ -1,9 +1,10 @@ +import { subDays } from 'date-fns'; import { ReadOnlyDate } from '@app/components/behandling/behandlingsdetaljer/read-only-date'; import { DateContainer } from '@app/components/behandling/styled-components'; import { CURRENT_YEAR_IN_CENTURY } from '@app/components/date-picker/constants'; import { DatePicker } from '@app/components/date-picker/date-picker'; import { useOppgave } from '@app/hooks/oppgavebehandling/use-oppgave'; -import { useCanEdit } from '@app/hooks/use-can-edit'; +import { useCanEditBehandling } from '@app/hooks/use-can-edit'; import { useFieldName } from '@app/hooks/use-field-name'; import { useValidationError } from '@app/hooks/use-validation-error'; import { useSetMottattKlageinstansMutation } from '@app/redux-api/oppgaver/mutations/behandling-dates'; @@ -12,7 +13,7 @@ import { SaksTypeEnum } from '@app/types/kodeverk'; const ID = 'anke-mottatt-dato'; export const AnkeMottattDato = () => { - const canEdit = useCanEdit(); + const canEdit = useCanEditBehandling(); const { data } = useOppgave(); const error = useValidationError('mottattKlageinstans'); const label = useFieldName('mottattKlageinstans'); @@ -43,6 +44,7 @@ export const AnkeMottattDato = () => { id={ID} size="small" centuryThreshold={CURRENT_YEAR_IN_CENTURY} + warningThreshhold={subDays(new Date(), 360)} /> ); diff --git a/frontend/src/components/behandling/behandlingsdetaljer/ankebehandlingsdetaljer.tsx b/frontend/src/components/behandling/behandlingsdetaljer/ankebehandlingsdetaljer.tsx index 99dfd741e..ef9481e31 100644 --- a/frontend/src/components/behandling/behandlingsdetaljer/ankebehandlingsdetaljer.tsx +++ b/frontend/src/components/behandling/behandlingsdetaljer/ankebehandlingsdetaljer.tsx @@ -44,7 +44,7 @@ export const Ankebehandlingsdetaljer = ({ oppgavebehandling }: Props) => { updateKlager({ klager, oppgaveId: oppgavebehandling.id })} isLoading={klagerIsLoading} diff --git a/frontend/src/components/behandling/behandlingsdetaljer/behandling-etter-tr-opphevet-detaljer.tsx b/frontend/src/components/behandling/behandlingsdetaljer/behandling-etter-tr-opphevet-detaljer.tsx new file mode 100644 index 000000000..4f31cfff6 --- /dev/null +++ b/frontend/src/components/behandling/behandlingsdetaljer/behandling-etter-tr-opphevet-detaljer.tsx @@ -0,0 +1,101 @@ +import { Heading } from '@navikt/ds-react'; +import { ExtraUtfall } from '@app/components/behandling/behandlingsdetaljer/extra-utfall'; +import { GosysBeskrivelse } from '@app/components/behandling/behandlingsdetaljer/gosys/beskrivelse'; +import { ReadOnlyDate } from '@app/components/behandling/behandlingsdetaljer/read-only-date'; +import { Saksnummer } from '@app/components/behandling/behandlingsdetaljer/saksnummer'; +import { Type } from '@app/components/type/type'; +import { isoDateToPretty } from '@app/domain/date'; +import { useUpdateFullmektigMutation, useUpdateKlagerMutation } from '@app/redux-api/oppgaver/mutations/behandling'; +import { IBehandlingEtterTryderettenOpphevet as IBehandlingEtterTrOpphevet } from '@app/types/oppgavebehandling/oppgavebehandling'; +import { Part } from '../../part/part'; +import { StyledBehandlingSection } from '../styled-components'; +import { BehandlingSection } from './behandling-section'; +import { Lovhjemmel } from './lovhjemmel/lovhjemmel'; +import { MeldingFraVedtaksinstans } from './melding-fra-vedtaksinstans'; +import { UtfallResultat } from './utfall-resultat'; +import { Ytelse } from './ytelse'; + +interface Props { + oppgavebehandling: IBehandlingEtterTrOpphevet; +} + +export const BehandlingEtterTrOpphevetDetaljer = ({ oppgavebehandling }: Props) => { + const [updateFullmektig, { isLoading: fullmektigIsLoading }] = useUpdateFullmektigMutation(); + const [updateKlager, { isLoading: klagerIsLoading }] = useUpdateKlagerMutation(); + + const { + typeId, + fraNAVEnhetNavn, + fraNAVEnhet, + kommentarFraVedtaksinstans, + oppgavebeskrivelse, + resultat, + ytelseId, + prosessfullmektig, + saksnummer, + varsletFrist, + kjennelseMottatt, + } = oppgavebehandling; + + return ( + + + Behandling + + + updateKlager({ klager, oppgaveId: oppgavebehandling.id })} + isLoading={klagerIsLoading} + /> + + updateFullmektig({ fullmektig, oppgaveId: oppgavebehandling.id })} + isLoading={fullmektigIsLoading} + /> + + + + + + + + + + + + + + + {varsletFrist === null ? 'Ikke satt' : isoDateToPretty(varsletFrist)} + + + + {fraNAVEnhetNavn} - {fraNAVEnhet} + + + + + + + + + + + + + ); +}; diff --git a/frontend/src/components/behandling/behandlingsdetaljer/extra-utfall.tsx b/frontend/src/components/behandling/behandlingsdetaljer/extra-utfall.tsx index 59dd01174..91811e43f 100644 --- a/frontend/src/components/behandling/behandlingsdetaljer/extra-utfall.tsx +++ b/frontend/src/components/behandling/behandlingsdetaljer/extra-utfall.tsx @@ -4,7 +4,7 @@ import { styled } from 'styled-components'; import { Dropdown } from '@app/components/filter-dropdown/dropdown'; import { isUtfall } from '@app/functions/is-utfall'; import { useOppgave } from '@app/hooks/oppgavebehandling/use-oppgave'; -import { useCanEdit } from '@app/hooks/use-can-edit'; +import { useCanEditBehandling } from '@app/hooks/use-can-edit'; import { useOnClickOutside } from '@app/hooks/use-on-click-outside'; import { useUtfall } from '@app/hooks/use-utfall'; import { useUpdateExtraUtfallMutation } from '@app/redux-api/oppgaver/mutations/set-utfall'; @@ -20,7 +20,7 @@ interface Props extends TagsProps { } export const ExtraUtfall = (props: Props) => { - const canEdit = useCanEdit(); + const canEdit = useCanEditBehandling(); return ( @@ -85,7 +85,7 @@ const ExtraUtfallButton = ({ utfallIdSet, mainUtfall, oppgaveId }: Props) => { }; const ReadOnlyLabel = () => { - const canEdit = useCanEdit(); + const canEdit = useCanEditBehandling(); if (canEdit) { return null; @@ -108,7 +108,7 @@ const TAGSCONTAINER_ID = 'tags-container'; const Tags = ({ utfallIdSet, mainUtfall }: TagsProps) => { const { data: oppgave } = useOppgave(); const [utfallKodeverk] = useUtfall(oppgave?.typeId); - const canEdit = useCanEdit(); + const canEdit = useCanEditBehandling(); return ( <> diff --git a/frontend/src/components/behandling/behandlingsdetaljer/kjennelse-mottatt.tsx b/frontend/src/components/behandling/behandlingsdetaljer/kjennelse-mottatt.tsx index 8ad915696..0d626f33d 100644 --- a/frontend/src/components/behandling/behandlingsdetaljer/kjennelse-mottatt.tsx +++ b/frontend/src/components/behandling/behandlingsdetaljer/kjennelse-mottatt.tsx @@ -1,9 +1,10 @@ +import { subDays } from 'date-fns'; import { ReadOnlyDate } from '@app/components/behandling/behandlingsdetaljer/read-only-date'; import { DateContainer } from '@app/components/behandling/styled-components'; import { CURRENT_YEAR_IN_CENTURY } from '@app/components/date-picker/constants'; import { DatePicker } from '@app/components/date-picker/date-picker'; import { useOppgave } from '@app/hooks/oppgavebehandling/use-oppgave'; -import { useCanEdit } from '@app/hooks/use-can-edit'; +import { useCanEditBehandling } from '@app/hooks/use-can-edit'; import { useFieldName } from '@app/hooks/use-field-name'; import { useValidationError } from '@app/hooks/use-validation-error'; import { useSetKjennelseMottattMutation } from '@app/redux-api/oppgaver/mutations/behandling-dates'; @@ -12,7 +13,7 @@ import { SaksTypeEnum } from '@app/types/kodeverk'; const ID = 'kjennelse-mottatt'; export const KjennelseMottatt = () => { - const canEdit = useCanEdit(); + const canEdit = useCanEditBehandling(); const { data } = useOppgave(); const error = useValidationError('kjennelseMottatt'); const label = useFieldName('kjennelseMottatt'); @@ -45,6 +46,7 @@ export const KjennelseMottatt = () => { id={ID} size="small" centuryThreshold={CURRENT_YEAR_IN_CENTURY} + warningThreshhold={subDays(new Date(), 360)} /> ); diff --git a/frontend/src/components/behandling/behandlingsdetaljer/lovhjemmel/lovhjemmel.tsx b/frontend/src/components/behandling/behandlingsdetaljer/lovhjemmel/lovhjemmel.tsx index d6e11e520..ec985d85e 100644 --- a/frontend/src/components/behandling/behandlingsdetaljer/lovhjemmel/lovhjemmel.tsx +++ b/frontend/src/components/behandling/behandlingsdetaljer/lovhjemmel/lovhjemmel.tsx @@ -1,7 +1,7 @@ import { HelpText, Label } from '@navikt/ds-react'; import { styled } from 'styled-components'; import { useOppgave } from '@app/hooks/oppgavebehandling/use-oppgave'; -import { useCanEdit } from '@app/hooks/use-can-edit'; +import { useCanEditBehandling } from '@app/hooks/use-can-edit'; import { useValidationError } from '@app/hooks/use-validation-error'; import { useUpdateRegistreringshjemlerMutation } from '@app/redux-api/oppgaver/mutations/set-registreringshjemler'; import { LovhjemmelSelect } from './lovhjemmel-select'; @@ -12,7 +12,7 @@ const EMPTY_LIST: string[] = []; export const Lovhjemmel = () => { const [updateHjemler] = useUpdateRegistreringshjemlerMutation(); const { data: oppgave } = useOppgave(); - const canEdit = useCanEdit(); + const canEdit = useCanEditBehandling(); const validationError = useValidationError('hjemmel'); const selected = oppgave?.resultat.hjemmelIdSet ?? EMPTY_LIST; diff --git a/frontend/src/components/behandling/behandlingsdetaljer/mottatt-vedtaksinstans.tsx b/frontend/src/components/behandling/behandlingsdetaljer/mottatt-vedtaksinstans.tsx index 05f8c76eb..fdc81a2c2 100644 --- a/frontend/src/components/behandling/behandlingsdetaljer/mottatt-vedtaksinstans.tsx +++ b/frontend/src/components/behandling/behandlingsdetaljer/mottatt-vedtaksinstans.tsx @@ -1,9 +1,10 @@ +import { subDays } from 'date-fns'; import { ReadOnlyDate } from '@app/components/behandling/behandlingsdetaljer/read-only-date'; import { DateContainer } from '@app/components/behandling/styled-components'; import { CURRENT_YEAR_IN_CENTURY } from '@app/components/date-picker/constants'; import { DatePicker } from '@app/components/date-picker/date-picker'; import { useOppgave } from '@app/hooks/oppgavebehandling/use-oppgave'; -import { useCanEdit } from '@app/hooks/use-can-edit'; +import { useCanEditBehandling } from '@app/hooks/use-can-edit'; import { useFieldName } from '@app/hooks/use-field-name'; import { useValidationError } from '@app/hooks/use-validation-error'; import { useSetMottattVedtaksinstansMutation } from '@app/redux-api/oppgaver/mutations/behandling-dates'; @@ -12,7 +13,7 @@ import { SaksTypeEnum } from '@app/types/kodeverk'; const ID = 'mottatt-vedtaksinstans'; export const MottattVedtaksinstans = () => { - const canEdit = useCanEdit(); + const canEdit = useCanEditBehandling(); const { data } = useOppgave(); const error = useValidationError('mottattVedtaksinstans'); const label = useFieldName('mottattVedtaksinstans'); @@ -43,6 +44,7 @@ export const MottattVedtaksinstans = () => { id={ID} size="small" centuryThreshold={CURRENT_YEAR_IN_CENTURY} + warningThreshhold={subDays(new Date(), 360)} /> ); diff --git a/frontend/src/components/behandling/behandlingsdetaljer/sendt-til-trygderetten.tsx b/frontend/src/components/behandling/behandlingsdetaljer/sendt-til-trygderetten.tsx index 916a3915d..f01cd6884 100644 --- a/frontend/src/components/behandling/behandlingsdetaljer/sendt-til-trygderetten.tsx +++ b/frontend/src/components/behandling/behandlingsdetaljer/sendt-til-trygderetten.tsx @@ -1,7 +1,8 @@ +import { subDays } from 'date-fns'; import { ReadOnlyDate } from '@app/components/behandling/behandlingsdetaljer/read-only-date'; import { DateContainer } from '@app/components/behandling/styled-components'; import { useOppgave } from '@app/hooks/oppgavebehandling/use-oppgave'; -import { useCanEdit } from '@app/hooks/use-can-edit'; +import { useCanEditBehandling } from '@app/hooks/use-can-edit'; import { useFieldName } from '@app/hooks/use-field-name'; import { useValidationError } from '@app/hooks/use-validation-error'; import { useSetSendtTilTrygderettenMutation } from '@app/redux-api/oppgaver/mutations/behandling-dates'; @@ -12,7 +13,7 @@ import { DatePicker } from '../../date-picker/date-picker'; const ID = 'sendt-til-trygderetten'; export const SendtTilTrygderetten = () => { - const canEdit = useCanEdit(); + const canEdit = useCanEditBehandling(); const { data } = useOppgave(); const error = useValidationError('sendtTilTrygderetten'); const label = useFieldName('sendtTilTrygderetten'); @@ -43,6 +44,7 @@ export const SendtTilTrygderetten = () => { id={ID} size="small" centuryThreshold={CURRENT_YEAR_IN_CENTURY} + warningThreshhold={subDays(new Date(), 360)} /> ); diff --git a/frontend/src/components/behandling/behandlingsdetaljer/trygderettsankebehandlingsdetaljer.tsx b/frontend/src/components/behandling/behandlingsdetaljer/trygderettsankebehandlingsdetaljer.tsx index c2123f199..6b8f1f876 100644 --- a/frontend/src/components/behandling/behandlingsdetaljer/trygderettsankebehandlingsdetaljer.tsx +++ b/frontend/src/components/behandling/behandlingsdetaljer/trygderettsankebehandlingsdetaljer.tsx @@ -30,7 +30,7 @@ export const Trygderettsankebehandlingsdetaljer = ({ oppgavebehandling }: Props) Behandling - {oppgavebehandling.klager.name ?? 'Navn mangler'} + {oppgavebehandling.klager.name ?? 'Navn mangler'} { - const canEdit = useCanEdit(); + const canEdit = useCanEditBehandling(); return canEdit ? : ; }; diff --git a/frontend/src/components/common-table-components/open.tsx b/frontend/src/components/common-table-components/open.tsx index 572596fa7..d8f7208d1 100644 --- a/frontend/src/components/common-table-components/open.tsx +++ b/frontend/src/components/common-table-components/open.tsx @@ -45,49 +45,44 @@ export const OpenOppgavebehandling = ({ return null; } - if (typeId === SaksTypeEnum.KLAGE) { - return ( - - ); - } + const commonProps = { as: Link, variant, size, children, 'data-oppgavebehandlingid': id }; - if (typeId === SaksTypeEnum.ANKE) { - return ( - - ); + switch (typeId) { + case SaksTypeEnum.KLAGE: + return ( + - ); }; diff --git a/frontend/src/components/documents/new-documents/helpers.ts b/frontend/src/components/documents/new-documents/helpers.ts index 5e4523528..bcaaaa4e4 100644 --- a/frontend/src/components/documents/new-documents/helpers.ts +++ b/frontend/src/components/documents/new-documents/helpers.ts @@ -3,6 +3,3 @@ import { TemplateIdEnum } from '@app/types/smart-editor/template-enums'; export const getIsRolQuestions = (document: IMainDocument): document is ISmartDocument => document.isSmartDokument && document.templateId === TemplateIdEnum.ROL_QUESTIONS; - -export const getIsRolAnswers = (document: IMainDocument): document is ISmartDocument => - document.isSmartDokument && document.templateId === TemplateIdEnum.ROL_ANSWERS; diff --git a/frontend/src/components/documents/new-documents/new-attachment-buttons.tsx b/frontend/src/components/documents/new-documents/new-attachment-buttons.tsx index 353a3119d..3287248ef 100644 --- a/frontend/src/components/documents/new-documents/new-attachment-buttons.tsx +++ b/frontend/src/components/documents/new-documents/new-attachment-buttons.tsx @@ -6,12 +6,13 @@ import { styled } from 'styled-components'; import { StaticDataContext } from '@app/components/app/static-data-context'; import { getIsRolQuestions } from '@app/components/documents/new-documents/helpers'; import { UploadFileButton } from '@app/components/upload-file-button/upload-file-button'; +import { useOppgave } from '@app/hooks/oppgavebehandling/use-oppgave'; import { useOppgaveId } from '@app/hooks/oppgavebehandling/use-oppgave-id'; import { useIsFeilregistrert } from '@app/hooks/use-is-feilregistrert'; import { useIsFullfoert } from '@app/hooks/use-is-fullfoert'; import { useIsRol } from '@app/hooks/use-is-rol'; import { ROL_ANSWERS_TEMPLATE } from '@app/plate/templates/simple-templates'; -import { useCreateSmartDocumentMutation } from '@app/redux-api/oppgaver/mutations/smart-document'; +import { useCreateSmartDocumentMutation } from '@app/redux-api/collaboration'; import { Role } from '@app/types/bruker'; import { DistribusjonsType, IMainDocument } from '@app/types/documents/documents'; import { Language } from '@app/types/texts/language'; @@ -55,12 +56,12 @@ export const NewAttachmentButtons = ({ document }: Props) => { }; const NewRolAnswerDocumentButton = ({ document }: Props) => { - const oppgaveId = useOppgaveId(); + const { data: oppgave } = useOppgave(); const { user } = useContext(StaticDataContext); const isRol = useIsRol(); const [create, { isLoading }] = useCreateSmartDocumentMutation(); - if (oppgaveId === skipToken) { + if (oppgave === undefined) { return null; } @@ -70,12 +71,12 @@ const NewRolAnswerDocumentButton = ({ document }: Props) => { const onClick = () => create({ - oppgaveId, + oppgaveId: oppgave.id, parentId: document.id, creatorIdent: user.navIdent, creatorRole: Role.KABAL_ROL, tittel: 'Svar fra rådgivende overlege', - richText: ROL_ANSWERS_TEMPLATE.richText, + content: ROL_ANSWERS_TEMPLATE.richText, dokumentTypeId: ROL_ANSWERS_TEMPLATE.dokumentTypeId, templateId: ROL_ANSWERS_TEMPLATE.templateId, language: Language.NB, diff --git a/frontend/src/components/maltekstseksjoner/create.tsx b/frontend/src/components/maltekstseksjoner/create.tsx index 91cf9e221..c1717c89c 100644 --- a/frontend/src/components/maltekstseksjoner/create.tsx +++ b/frontend/src/components/maltekstseksjoner/create.tsx @@ -1,7 +1,6 @@ import { PadlockLockedIcon, PencilWritingIcon, PlusIcon } from '@navikt/aksel-icons'; import { Button } from '@navikt/ds-react'; import { useCallback } from 'react'; -import { useSearchParams } from 'react-router-dom'; import { styled } from 'styled-components'; import { getNewRichText } from '@app/components/smart-editor-texts/functions/new-text'; import { useNavigateMaltekstseksjoner } from '@app/hooks/use-navigate-maltekstseksjoner'; @@ -22,7 +21,6 @@ interface Props { export const CreateMaltekstseksjon = ({ query }: Props) => { const [createMaltekstseksjon, { isLoading }] = useCreateMaltekstseksjonMutation(); const setPath = useNavigateMaltekstseksjoner(); - const [searchParams, setSearchParams] = useSearchParams(); const create = useCallback(async () => { const maltekstseksjon: INewMaltekstseksjonParams['maltekstseksjon'] = { @@ -35,11 +33,8 @@ export const CreateMaltekstseksjon = ({ query }: Props) => { }; const { id, versionId } = await createMaltekstseksjon({ maltekstseksjon, query }).unwrap(); - setPath({ maltekstseksjonId: id, maltekstseksjonVersionId: versionId }); - - searchParams.delete('trash'); - setSearchParams(searchParams); - }, [createMaltekstseksjon, query, searchParams, setSearchParams, setPath]); + setPath({ maltekstseksjonId: id, maltekstseksjonVersionId: versionId, trash: false }); + }, [createMaltekstseksjon, query, setPath]); return ( + + ); +}; diff --git a/frontend/src/components/search/common/oppgaver.tsx b/frontend/src/components/search/common/oppgaver.tsx new file mode 100644 index 000000000..e4b75f7e0 --- /dev/null +++ b/frontend/src/components/search/common/oppgaver.tsx @@ -0,0 +1,105 @@ +import { Skeleton, Table } from '@navikt/ds-react'; +import { TypedUseQueryHookResult } from '@reduxjs/toolkit/query/react'; +import { styled } from 'styled-components'; +import { ErrorAlert } from '@app/components/search/common/error-alert'; +import { FeilregistrerteOppgaverTable } from '@app/components/search/common/feilregistrerte-oppgaver-table'; +import { FullfoerteOppgaverTable } from '@app/components/search/common/fullfoerte-oppgaver-table'; +import { LedigeOppgaverTable } from '@app/components/search/common/ledige-oppgaver-table'; +import { OppgaverPaaVentTable } from '@app/components/search/common/oppgaver-paa-vent-table'; +import { OppgaverPageWrapper } from '@app/pages/page-wrapper'; +import { staggeredBaseQuery } from '@app/redux-api/common'; +import { IOppgaverResponse } from '@app/types/oppgaver'; + +// https://github.com/reduxjs/redux-toolkit/issues/1937#issuecomment-1842868277 +// https://redux-toolkit.js.org/rtk-query/usage-with-typescript#typing-query-and-mutation-endpoints +type OppgaverHookResult = TypedUseQueryHookResult>; +export type OppgaverQuery = Omit & { refetch: () => void }; + +export const Oppgaver = ({ data, isFetching, isLoading, error, refetch }: OppgaverQuery) => { + if (isLoading) { + return ; + } + + if (error !== undefined) { + return ( + + + Feil ved henting av oppgaver + + + ); + } + + if (data === undefined) { + return null; + } + + const footerProps = { onRefresh: refetch, isLoading: isFetching }; + + return ( + + + + + + + ); +}; + +const ErrorContainer = styled.div` + display: flex; + margin: 16px; +`; + +const SkeletonTables = () => ( + + + + + +
+); + +const SkeletonTable = () => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +); diff --git a/frontend/src/components/search/common/styled-components.ts b/frontend/src/components/search/common/styled-components.ts index d120e2135..3186c2064 100644 --- a/frontend/src/components/search/common/styled-components.ts +++ b/frontend/src/components/search/common/styled-components.ts @@ -1,16 +1,5 @@ import { styled } from 'styled-components'; -export const StyledOppgaverContainer = styled.section` - grid-area: oppgaver; - border-top: 1px solid #c6c2bf; - margin-top: 16px; - padding-top: 8px; - display: flex; - flex-direction: column; - gap: 75px; - width: fit-content; -`; - export const StyledName = styled.span` justify-self: left; overflow: hidden; diff --git a/frontend/src/components/search/fnr/fnr-search.tsx b/frontend/src/components/search/fnr/fnr-search.tsx index 9256bb1bc..1061125ec 100644 --- a/frontend/src/components/search/fnr/fnr-search.tsx +++ b/frontend/src/components/search/fnr/fnr-search.tsx @@ -1,93 +1,15 @@ -import { MagnifyingGlassIcon } from '@navikt/aksel-icons'; -import { Alert, Button, Loader } from '@navikt/ds-react'; -import { skipToken } from '@reduxjs/toolkit/query'; -import { styled } from 'styled-components'; -import { formatFoedselsnummer } from '@app/functions/format-id'; -import { useSearchOppgaverByFnrQuery, useSearchPersonByFnrQuery } from '@app/redux-api/oppgaver/queries/oppgaver'; -import { Result } from './result'; - -const FNR_REGEX = /^\s*\d{6}\s*\d{5}\s*$/; -const isFnr = (query: string) => FNR_REGEX.test(query); -const cleanFnr = (fnr: string) => fnr.replaceAll(/\s+/gi, ''); +import { Person, PersonQuery } from '@app/components/search/fnr/person'; +import { Oppgaver, OppgaverQuery } from '../common/oppgaver'; interface Props { - queryString: string; + personQuery: PersonQuery; + oppgaverQuery: OppgaverQuery; + fnr: string; } -export const FnrSearch = ({ queryString }: Props) => { - const query = useCleanQuery(queryString); - const { - data: oppgaver, - isLoading: oppgaverIsLoading, - isFetching: oppgaverIsFetching, - refetch: refetchOppgaver, - } = useSearchOppgaverByFnrQuery(query); - const { - data: person, - isLoading: personIsLoading, - isFetching: personIsFetching, - refetch: refetchPerson, - } = useSearchPersonByFnrQuery(query); - - if (query === skipToken) { - return null; - } - - if (oppgaverIsLoading || personIsLoading) { - return ; - } - - if (typeof oppgaver === 'undefined') { - return ( - - - Ingen registrerte oppgaver på denne personen i Kabal. - - - - ); - } - - if (typeof person === 'undefined') { - return ( - - - Fant ingen person med ID-nummer {formatFoedselsnummer(query)}. - - - - ); - } - - return ; -}; - -const useCleanQuery = (queryString: string): string | typeof skipToken => { - if (!isFnr(queryString)) { - return skipToken; - } - - return cleanFnr(queryString); -}; - -const AlertContent = styled.div` - display: flex; - align-items: center; - gap: 8px; -`; +export const FnrSearch = ({ oppgaverQuery, personQuery, fnr }: Props) => ( + <> + + + +); diff --git a/frontend/src/components/search/fnr/person.tsx b/frontend/src/components/search/fnr/person.tsx new file mode 100644 index 000000000..f9c24f16a --- /dev/null +++ b/frontend/src/components/search/fnr/person.tsx @@ -0,0 +1,75 @@ +import { MagnifyingGlassIcon } from '@navikt/aksel-icons'; +import { Button, Skeleton } from '@navikt/ds-react'; +import { TypedUseQueryHookResult } from '@reduxjs/toolkit/query/react'; +import { styled } from 'styled-components'; +import { CopyIdButton } from '@app/components/copy-button/copy-id-button'; +import { ErrorAlert } from '@app/components/search/common/error-alert'; +import { StyledFnr, StyledName } from '@app/components/search/common/styled-components'; +import { formatFoedselsnummer } from '@app/functions/format-id'; +import { staggeredBaseQuery } from '@app/redux-api/common'; +import { IPartBase } from '@app/types/oppgave-common'; + +// https://github.com/reduxjs/redux-toolkit/issues/1937#issuecomment-1842868277 +// https://redux-toolkit.js.org/rtk-query/usage-with-typescript#typing-query-and-mutation-endpoints +export type PersonQuery = TypedUseQueryHookResult>; + +type PersonProps = PersonQuery & { fnr: string }; + +export const Person = ({ data, isLoading, isFetching, error, fnr, refetch }: PersonProps) => { + if (isLoading) { + return ( + + + + + + ); + } + + if (error !== undefined) { + return ( + + + {`Fant ingen person med ID-nummer ${formatFoedselsnummer(fnr)}`} + + + ); + } + + if (data === undefined) { + return null; + } + + return ( + + {data.name} + + + + + + ); +}; + +const StyledPerson = styled.div` + display: flex; + flex-direction: row; + align-items: center; + column-gap: 16px; + padding-left: 16px; + padding-right: 16px; +`; + +const SkeletonContainer = styled.div` + display: flex; + gap: 16px; + margin-left: 16px; +`; diff --git a/frontend/src/components/search/fnr/result.tsx b/frontend/src/components/search/fnr/result.tsx deleted file mode 100644 index be26a5fd4..000000000 --- a/frontend/src/components/search/fnr/result.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { MagnifyingGlassIcon } from '@navikt/aksel-icons'; -import { Button } from '@navikt/ds-react'; -import { styled } from 'styled-components'; -import { FeilregistrerteOppgaverTable } from '@app/components/search/common/feilregistrerte-oppgaver-table'; -import { OppgaverPaaVentTable } from '@app/components/search/common/oppgaver-paa-vent-table'; -import { OppgaverPageWrapper } from '@app/pages/page-wrapper'; -import { IPartBase } from '@app/types/oppgave-common'; -import { IOppgaverResponse } from '@app/types/oppgaver'; -import { CopyIdButton } from '../../copy-button/copy-id-button'; -import { FullfoerteOppgaverTable } from '../common/fullfoerte-oppgaver-table'; -import { LedigeOppgaverTable } from '../common/ledige-oppgaver-table'; -import { StyledFnr, StyledName } from '../common/styled-components'; - -interface Props extends IOppgaverResponse { - person: IPartBase; - onRefresh: () => void; - isLoading: boolean; -} - -export const Result = ({ - person, - aapneBehandlinger, - avsluttedeBehandlinger, - feilregistrerteBehandlinger, - paaVentBehandlinger, - ...footerProps -}: Props) => ( - <> - - {person.name} - - - - - - - - - - - - -); - -const StyledPerson = styled.div` - display: flex; - flex-direction: row; - align-items: center; - column-gap: 16px; - padding-left: 16px; - padding-right: 16px; -`; diff --git a/frontend/src/components/search/name/name-search.tsx b/frontend/src/components/search/name/name-search.tsx deleted file mode 100644 index 5181d7778..000000000 --- a/frontend/src/components/search/name/name-search.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import { MagnifyingGlassIcon } from '@navikt/aksel-icons'; -import { Alert, Button, Loader } from '@navikt/ds-react'; -import { skipToken } from '@reduxjs/toolkit/query'; -import { useCallback, useEffect, useMemo } from 'react'; -import { styled } from 'styled-components'; -import { useLazySearchPeopleByNameQuery } from '@app/redux-api/oppgaver/queries/oppgaver'; -import { INameSearchParams } from '@app/types/oppgaver'; -import { SearchResults } from './searchresults'; - -interface NameSearchProps { - queryString: string; -} - -const NUMBER_REGEX = /\d+/; -const containsNumber = (query: string) => NUMBER_REGEX.test(query); - -export const NameSearch = ({ queryString }: NameSearchProps) => { - const query = useGetQuery(queryString); - const [_search, { data, isLoading, isFetching }] = useLazySearchPeopleByNameQuery(); - - const search = useCallback(() => { - if (query !== skipToken) { - _search(query); - } - }, [query, _search]); - - useEffect(() => { - const timeout = setTimeout(search, 1000); - - return () => clearTimeout(timeout); - }, [search]); - - if (query === skipToken) { - return null; - } - - if (typeof data === 'undefined') { - return ; - } - - if (data.people.length === 0) { - return ( - - - Ingen registrerte oppgaver på denne personen i Kabal. - - - - ); - } - - return ; -}; - -const useGetQuery = (queryString: string): INameSearchParams | typeof skipToken => - useMemo(() => { - if (queryString.length === 0) { - return skipToken; - } - - if (containsNumber(queryString)) { - return skipToken; - } - - return { query: queryString, antall: 200, start: 0 }; - }, [queryString]); - -const AlertContent = styled.div` - display: flex; - align-items: center; - gap: 8px; -`; diff --git a/frontend/src/components/search/name/oppgaver.tsx b/frontend/src/components/search/name/oppgaver.tsx deleted file mode 100644 index 8b27a6c41..000000000 --- a/frontend/src/components/search/name/oppgaver.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { Loader } from '@navikt/ds-react'; -import { FeilregistrerteOppgaverTable } from '@app/components/search/common/feilregistrerte-oppgaver-table'; -import { OppgaverPaaVentTable } from '@app/components/search/common/oppgaver-paa-vent-table'; -import { useSearchOppgaverByFnrQuery } from '@app/redux-api/oppgaver/queries/oppgaver'; -import { FullfoerteOppgaverTable } from '../common/fullfoerte-oppgaver-table'; -import { LedigeOppgaverTable } from '../common/ledige-oppgaver-table'; -import { StyledOppgaverContainer } from '../common/styled-components'; - -interface Props { - fnr: string; -} - -export const Oppgaver = ({ fnr }: Props) => { - const { data, isFetching, isLoading, refetch } = useSearchOppgaverByFnrQuery(fnr, { - refetchOnFocus: true, - refetchOnMountOrArgChange: true, - }); - - if (isLoading || typeof data === 'undefined') { - return ; - } - - const { aapneBehandlinger, avsluttedeBehandlinger, feilregistrerteBehandlinger, paaVentBehandlinger } = data; - - return ( - - - - - - - ); -}; diff --git a/frontend/src/components/search/name/result.tsx b/frontend/src/components/search/name/result.tsx deleted file mode 100644 index 4fc703192..000000000 --- a/frontend/src/components/search/name/result.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { Table } from '@navikt/ds-react'; -import { useState } from 'react'; -import { IPartBase } from '@app/types/oppgave-common'; -import { CopyIdButton } from '../../copy-button/copy-id-button'; -import { StyledFnr, StyledName } from '../common/styled-components'; -import { Oppgaver } from './oppgaver'; - -export const Result = ({ id, name }: IPartBase) => { - const [isOpen, setIsOpen] = useState(false); - - return ( - : null} - onOpenChange={setIsOpen} - data-testid="search-result" - > - - {name} - - - - - - - - - ); -}; diff --git a/frontend/src/components/search/name/searchresults.tsx b/frontend/src/components/search/name/searchresults.tsx deleted file mode 100644 index b293d032a..000000000 --- a/frontend/src/components/search/name/searchresults.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { ArrowsCirclepathIcon } from '@navikt/aksel-icons'; -import { Button, Pagination, Table } from '@navikt/ds-react'; -import { useMemo, useState } from 'react'; -import { styled } from 'styled-components'; -import { PageInfo } from '@app/components/common-table-components/page-info'; -import { RowsPerPage } from '@app/components/rows-per-page'; -import { useRestrictedNumberSetting } from '@app/hooks/settings/helpers'; -import { restrictPageSize } from '@app/hooks/use-oppgave-pagination'; -import { StyledFooterContent } from '@app/styled-components/table'; -import { IPartBase } from '@app/types/oppgave-common'; -import { Result } from './result'; - -interface SearchResultsProps { - people: IPartBase[]; - onRefresh: () => void; - isLoading: boolean; - isFetching: boolean; -} - -const SETTINGS_KEY = 'search/name/rows_per_page'; - -export const SearchResults = ({ people, onRefresh, isFetching, isLoading }: SearchResultsProps) => { - const [page, setPage] = useState(1); - const { value: pageSize } = useRestrictedNumberSetting(SETTINGS_KEY, restrictPageSize); - - const total = people.length; - const from = (page - 1) * pageSize; - const to = Math.min(total, from + pageSize); - - const slicedPeople = useMemo(() => people.slice(from, from + pageSize), [pageSize, from, people]); - - return ( - - - - - - Navn - Fødselsnummer - - - - {slicedPeople.map((person) => ( - - ))} - - - - - - - -
-
- ); -}; - -const Container = styled.div` - display: flex; - flex-direction: column; - overflow: auto; - padding-left: 16px; - padding-right: 16px; - padding-bottom: 16px; -`; - -const Left = styled.div` - display: flex; - align-items: center; - gap: 4px; -`; diff --git a/frontend/src/components/search/oppgave-search.tsx b/frontend/src/components/search/oppgave-search.tsx new file mode 100644 index 000000000..965945493 --- /dev/null +++ b/frontend/src/components/search/oppgave-search.tsx @@ -0,0 +1,140 @@ +import { ErrorMessage, Search, ToggleGroup } from '@navikt/ds-react'; +import { dnr, fnr } from '@navikt/fnrvalidator'; +import { skipToken } from '@reduxjs/toolkit/query'; +import { useState } from 'react'; +import { styled } from 'styled-components'; +import { Oppgaver } from '@app/components/search/common/oppgaver'; +import { FnrSearch } from '@app/components/search/fnr/fnr-search'; +import { + useLazySearchOppgaverBySaksnummerQuery, + useSearchOppgaverByFnrQuery, + useSearchPersonByFnrQuery, +} from '@app/redux-api/oppgaver/queries/oppgaver'; + +enum SearchType { + SAKSNR = 'SAKSNR', + FNR = 'FNR', +} + +const SEARCH_TYPES = Object.values(SearchType); +const isSearchType = (searchType: string): searchType is SearchType => SEARCH_TYPES.some((type) => type === searchType); + +const isFnr = (str: string) => fnr(str).status === 'valid' || dnr(str).status === 'valid'; +const removeWhitespace = (str: string) => str.replaceAll(/\s+/gi, ''); + +export const OppgaveSearch = () => { + const [searchType, setSearchType] = useState(SearchType.FNR); + const [rawQuery, setRawQuery] = useState(''); + const [fnrError, setFnrError] = useState(); + const [searchSaksnr, saksnrQuery] = useLazySearchOppgaverBySaksnummerQuery(); + + const query = removeWhitespace(rawQuery); + const fnrQueryString = searchType === SearchType.FNR && isFnr(query) ? query : skipToken; + const personQuery = useSearchPersonByFnrQuery(fnrQueryString); + const oppgaverQuery = useSearchOppgaverByFnrQuery(fnrQueryString); + + const fetchBySaksnr = () => searchSaksnr(query); + + const refetchByFnr = () => { + personQuery.refetch(); + oppgaverQuery.refetch(); + }; + + const getText = () => { + switch (searchType) { + case SearchType.FNR: + return 'Søk på fødselsnummer'; + case SearchType.SAKSNR: + return 'Søk på saksnummer'; + } + }; + + const search = () => { + switch (searchType) { + case SearchType.FNR: + if (!isFnr(query)) { + return setFnrError('Ugyldig fødselsnummer/D-nummer'); + } + + return refetchByFnr(); + case SearchType.SAKSNR: + setFnrError(undefined); + + return fetchBySaksnr(); + } + }; + + const renderSearchType = () => { + switch (searchType) { + case SearchType.FNR: + return ; + case SearchType.SAKSNR: + return ; + } + }; + + const text = getText(); + + return ( + <> + + + { + if (isSearchType(v)) { + setSearchType(v); + setFnrError(undefined); + } + }} + > + + + + { + setRawQuery(v); + + if (isFnr(v)) { + setFnrError(undefined); + } + }} + data-testid="search-input" + placeholder={text} + label={text} + onKeyDown={({ key }) => { + if (key === 'Enter') { + search(); + } + }} + > + + + + {fnrError} + + {renderSearchType()} + + ); +}; + +const Container = styled.div` + display: flex; + flex-direction: column; + gap: 8px; + padding: 16px; +`; + +const StyledSearch = styled(Search)` + width: 270px; +`; + +const Line = styled.div` + display: flex; + gap: 2px; +`; diff --git a/frontend/src/components/searchbox/searchbox.tsx b/frontend/src/components/searchbox/searchbox.tsx deleted file mode 100644 index 13ee56248..000000000 --- a/frontend/src/components/searchbox/searchbox.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { Search } from '@navikt/ds-react'; -import { styled } from 'styled-components'; - -interface SearchBoxProps { - setQuery: (query: string) => void; -} - -export const SearchBox = ({ setQuery }: SearchBoxProps): JSX.Element => ( - - setTimeout(() => setQuery(value), 0)} - data-testid="search-input" - placeholder="Søk på navn eller fødselsnummer" - label="Søk på navn eller fødselsnummer" - /> - -); - -const StyledSearch = styled(Search)` - max-width: 40em; -`; - -const StyledContainer = styled.div` - padding: 16px; -`; diff --git a/frontend/src/components/smart-editor-texts/edit/edit.tsx b/frontend/src/components/smart-editor-texts/edit/edit.tsx index 2c1415ff4..7658930d3 100644 --- a/frontend/src/components/smart-editor-texts/edit/edit.tsx +++ b/frontend/src/components/smart-editor-texts/edit/edit.tsx @@ -4,7 +4,6 @@ import { SavedStatusProps } from '@app/components/saved-status/saved-status'; import { Footer } from '@app/components/smart-editor-texts/edit/footer'; import { Tags } from '@app/components/smart-editor-texts/edit/tags'; import { useMetadataFilters } from '@app/components/smart-editor-texts/hooks/use-metadata-filters'; -import { useKlageenheterOptions } from '@app/components/smart-editor-texts/hooks/use-options'; import { KlageenhetSelect, TemplateSectionSelect } from '@app/components/smart-editor-texts/query-filter-selects'; import { UtfallSetFilter } from '@app/components/smart-editor-texts/utfall-set-filter/utfall-set-filter'; import { @@ -43,7 +42,6 @@ export const Edit = ({ text, onDraftDeleted, children, status, onPublish, delete const { id, created, ytelseHjemmelIdList, utfallIdList, enhetIdList, templateSectionIdList, title, textType } = text; - const klageenheterOptions = useKlageenheterOptions(); const { enhet, templateSection, utfall, ytelseHjemmel } = useMetadataFilters(textType); const [lastEditor] = text.editors.filter( @@ -104,7 +102,6 @@ export const Edit = ({ text, onDraftDeleted, children, status, onPublish, delete updateEnhetIdList({ id, query, enhetIdList: value })} - options={klageenheterOptions} > Enheter diff --git a/frontend/src/components/smart-editor-texts/filters.tsx b/frontend/src/components/smart-editor-texts/filters.tsx index 89382922b..b080e5aca 100644 --- a/frontend/src/components/smart-editor-texts/filters.tsx +++ b/frontend/src/components/smart-editor-texts/filters.tsx @@ -1,13 +1,12 @@ import { useSearchParams } from 'react-router-dom'; import { styled } from 'styled-components'; import { useMetadataFilters } from '@app/components/smart-editor-texts/hooks/use-metadata-filters'; -import { useKlageenheterOptions, useUtfallOptions } from '@app/components/smart-editor-texts/hooks/use-options'; +import { useUtfallOptions } from '@app/components/smart-editor-texts/hooks/use-options'; import { KlageenhetSelect, TemplateSectionSelect, UtfallSelect, } from '@app/components/smart-editor-texts/query-filter-selects'; -import { NONE_OPTION } from '@app/components/smart-editor-texts/types'; import { IGetMaltekstseksjonParams, TextTypes } from '@app/types/common-text-types'; import { HjemlerSelect } from './hjemler-select/hjemler-select'; import { useTextQuery } from './hooks/use-text-query'; @@ -24,7 +23,6 @@ export const Filters = ({ textType, className }: Props) => { const { enhetIdList, utfallIdList, templateSectionIdList, ytelseHjemmelIdList } = useTextQuery(); const utfallOptions = useUtfallOptions(); - const klageenheterOptions = useKlageenheterOptions(); const setFilter = (filter: keyof IGetMaltekstseksjonParams, values: string[]) => { if (values.length === 0) { @@ -74,7 +72,7 @@ export const Filters = ({ textType, className }: Props) => { setFilter('enhetIdList', value)} - options={[NONE_OPTION, ...klageenheterOptions]} + includeNoneOption > Enheter diff --git a/frontend/src/components/smart-editor-texts/hooks/use-text-navigate.ts b/frontend/src/components/smart-editor-texts/hooks/use-text-navigate.ts deleted file mode 100644 index 8b1e1a376..000000000 --- a/frontend/src/components/smart-editor-texts/hooks/use-text-navigate.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { useCallback } from 'react'; -import { useLocation, useNavigate } from 'react-router'; -import { useRedaktoerLanguage } from '@app/hooks/use-redaktoer-language'; -import { getPathPrefix } from '../functions/get-path-prefix'; -import { useTextQuery } from './use-text-query'; -import { useTextType } from './use-text-type'; - -type GoToTextFn = (id: string) => void; - -export const useTextNavigate = (): GoToTextFn => { - const navigate = useNavigate(); - const query = useTextQuery(); - const textType = useTextType(); - const { search } = useLocation(); - const lang = useRedaktoerLanguage(); - - const goToTextFn = useCallback( - (id: string) => { - if (query === undefined) { - return; - } - - const pathPrefix = getPathPrefix(textType); - - return navigate(`${pathPrefix}/${lang}/${id}${search}`); - }, - [query, textType, navigate, lang, search], - ); - - return goToTextFn; -}; diff --git a/frontend/src/components/smart-editor-texts/query-filter-selects.tsx b/frontend/src/components/smart-editor-texts/query-filter-selects.tsx index 51c6f5222..453f7abbe 100644 --- a/frontend/src/components/smart-editor-texts/query-filter-selects.tsx +++ b/frontend/src/components/smart-editor-texts/query-filter-selects.tsx @@ -2,6 +2,7 @@ import { useCallback, useMemo, useRef, useState } from 'react'; import { styled } from 'styled-components'; import { NestedFilterList, NestedOption } from '@app/components/filter-dropdown/nested-filter-list'; import { IOption } from '@app/components/filter-dropdown/props'; +import { useKlageenheterOptions } from '@app/components/smart-editor-texts/hooks/use-options'; import { NONE, NONE_OPTION, NONE_TYPE, SET_DELIMITER } from '@app/components/smart-editor-texts/types'; import { ToggleButton } from '@app/components/toggle-button/toggle-button'; import { isUtfall } from '@app/functions/is-utfall'; @@ -76,14 +77,30 @@ interface KlageenhetSelectProps { children: string; selected: string[]; onChange: (value: string[]) => void; - options: IOption[]; + includeNoneOption?: boolean; } -export const KlageenhetSelect = ({ children, selected, onChange, options }: KlageenhetSelectProps) => ( - - {children} - -); +// Styringsenheten er ikke en klageenhet. +// De må likevel være med i listen man kan velge fra når man legger inn topp- og bunntekster. +// Dette er fordi de er med i et pilotprosjekt hvor det kan forekomme at de selv må saksbehandle. +const STYRINGSENHETEN = { value: '4200', label: 'NAV Klageinstans styringsenhet' }; + +export const KlageenhetSelect = ({ + children, + selected, + onChange, + includeNoneOption = false, +}: KlageenhetSelectProps) => { + const klageenheter = useKlageenheterOptions(); + const enheter = [...klageenheter, STYRINGSENHETEN]; + const options = includeNoneOption ? [NONE_OPTION, ...enheter] : enheter; + + return ( + + {children} + + ); +}; interface TemplateSelectProps { children: string; diff --git a/frontend/src/components/smart-editor-texts/smart-editor-texts.tsx b/frontend/src/components/smart-editor-texts/smart-editor-texts.tsx index f5f64d7ba..174ecd96f 100644 --- a/frontend/src/components/smart-editor-texts/smart-editor-texts.tsx +++ b/frontend/src/components/smart-editor-texts/smart-editor-texts.tsx @@ -1,7 +1,6 @@ import { PlusIcon } from '@navikt/aksel-icons'; import { Button } from '@navikt/ds-react'; import { useCallback } from 'react'; -import { useSearchParams } from 'react-router-dom'; import { styled } from 'styled-components'; import { SetStandaloneTextLanguage } from '@app/components/set-redaktoer-language/set-standalone-text-language'; import { useTextQuery } from '@app/components/smart-editor-texts/hooks/use-text-query'; @@ -12,14 +11,14 @@ import { isRegelverkType, isRichTextType, } from '@app/functions/is-rich-plain-text'; +import { useNavigateToStandaloneTextVersion } from '@app/hooks/use-navigate-to-standalone-text-version'; import { useRedaktoerLanguage } from '@app/hooks/use-redaktoer-language'; import { useAddTextMutation } from '@app/redux-api/texts/mutations'; -import { TextTypes } from '@app/types/common-text-types'; +import { REGELVERK_TYPE, TextTypes } from '@app/types/common-text-types'; import { Language } from '@app/types/texts/language'; import { LoadText } from './edit/load-text'; import { FilteredTextList } from './filtered-text-list'; import { getNewGodFormulering, getNewPlainText, getNewRegelverk, getNewRichText } from './functions/new-text'; -import { useTextNavigate } from './hooks/use-text-navigate'; interface Props { textType: TextTypes; @@ -27,18 +26,15 @@ interface Props { export const SmartEditorTexts = ({ textType }: Props) => { const query = useTextQuery(); - const navigate = useTextNavigate(); + const navigate = useNavigateToStandaloneTextVersion(textType !== REGELVERK_TYPE); const [addText, { isLoading }] = useAddTextMutation(); const lang = useRedaktoerLanguage(); - const [searchParams, setSearchParams] = useSearchParams(); const onClick = useCallback(async () => { const text = getNewText(textType, lang); - const { id } = await addText({ text, query }).unwrap(); - navigate(id); - searchParams.delete('trash'); - setSearchParams(searchParams); - }, [addText, lang, navigate, query, searchParams, setSearchParams, textType]); + const { id, versionId } = await addText({ text, query }).unwrap(); + navigate({ id, versionId, trash: false }); + }, [addText, lang, navigate, query, textType]); return ( diff --git a/frontend/src/components/smart-editor/bookmarks/bookmarks.tsx b/frontend/src/components/smart-editor/bookmarks/bookmarks.tsx index a54a97894..42a5926f8 100644 --- a/frontend/src/components/smart-editor/bookmarks/bookmarks.tsx +++ b/frontend/src/components/smart-editor/bookmarks/bookmarks.tsx @@ -1,23 +1,19 @@ import { BookmarkFillIcon, TrashIcon } from '@navikt/aksel-icons'; import { Button } from '@navikt/ds-react'; -import { TNode, getNodeString, isText, setNodes, toDOMNode } from '@udecode/plate-common'; -import { useContext } from 'react'; +import { TNode, getNodeString, setNodes, toDOMNode } from '@udecode/plate-common'; import { styled } from 'styled-components'; -import { BOOKMARK_PREFIX } from '@app/components/smart-editor/constants'; -import { SmartEditorContext } from '@app/components/smart-editor/context'; +import { useBookmarks } from '@app/components/smart-editor/bookmarks/use-bookmarks'; import { pushEvent } from '@app/observability'; -import { RichText, RichTextEditor, useMyPlateEditorState } from '@app/plate/types'; +import { useMyPlateEditorState } from '@app/plate/types'; interface Props { editorId: string; } export const Bookmarks = ({ editorId }: Props) => { - const { bookmarksMap, removeBookmark } = useContext(SmartEditorContext); + const bookmarks = useBookmarks(); const editor = useMyPlateEditorState(editorId); - const bookmarks = Object.entries(bookmarksMap); - if (bookmarks.length === 0) { return null; } @@ -58,7 +54,6 @@ export const Bookmarks = ({ editorId }: Props) => { onClick={() => { pushEvent('remove-bookmark', 'smart-editor'); setNodes(editor, { [key]: undefined }, { match: (n) => key in n, mode: 'lowest', at: [] }); - removeBookmark(key); }} icon={} /> @@ -69,32 +64,6 @@ export const Bookmarks = ({ editorId }: Props) => { ); }; -export const getBookmarks = (editor: RichTextEditor): Record => { - const bookmarkEntries = editor.nodes({ - match: (n) => isText(n) && Object.keys(n).some((k) => k.startsWith(BOOKMARK_PREFIX)), - at: [], - }); - - const bookmarkMap: Map = new Map(); - - for (const [node] of bookmarkEntries) { - const keys = Object.keys(node).filter((k) => k.startsWith(BOOKMARK_PREFIX)); - - for (const key of keys) { - const existing = bookmarkMap.get(key); - bookmarkMap.set(key, existing !== undefined ? [...existing, node] : [node]); - } - } - - if (bookmarkMap.size === 0) { - return EMPTY_BOOKMARKS; - } - - return Object.fromEntries(bookmarkMap.entries()); -}; - -const EMPTY_BOOKMARKS: Record = {}; - const BookmarkList = styled.ul` display: flex; flex-direction: column; diff --git a/frontend/src/components/smart-editor/bookmarks/positioned.tsx b/frontend/src/components/smart-editor/bookmarks/positioned.tsx index b46172c8d..2b1a056d6 100644 --- a/frontend/src/components/smart-editor/bookmarks/positioned.tsx +++ b/frontend/src/components/smart-editor/bookmarks/positioned.tsx @@ -1,9 +1,9 @@ import { BookmarkFillIcon, TrashFillIcon } from '@navikt/aksel-icons'; import { Tooltip } from '@navikt/ds-react'; import { setNodes } from '@udecode/plate-common'; -import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useContext, useMemo, useState } from 'react'; import { styled } from 'styled-components'; -import { getBookmarks } from '@app/components/smart-editor/bookmarks/bookmarks'; +import { useBookmarks } from '@app/components/smart-editor/bookmarks/use-bookmarks'; import { SmartEditorContext } from '@app/components/smart-editor/context'; import { BookmarkData, @@ -14,54 +14,38 @@ import { import { EDITOR_SCALE_CSS_VAR } from '@app/components/smart-editor/hooks/use-scale'; import { pushEvent } from '@app/observability'; import { BASE_FONT_SIZE } from '@app/plate/components/get-scaled-em'; -import { useMyPlateEditorState } from '@app/plate/types'; +import { useMyPlateEditorRef } from '@app/plate/types'; const ITEM_WIDTH = 1.5; const ITEM_GAP = 0.2; const ITEM_OFFSET = ITEM_WIDTH + ITEM_GAP; export const PositionedBookmarks = () => { - const { sheetRef, bookmarksMap, setInitialBookmarks, removeBookmark } = useContext(SmartEditorContext); - const editor = useMyPlateEditorState(); - const isInitalized = useRef(false); + const { sheetRef } = useContext(SmartEditorContext); + const bookmarksList = useBookmarks(); + const editorRef = useMyPlateEditorRef(); - useEffect(() => { - if (!isInitalized.current) { - isInitalized.current = true; - setInitialBookmarks(getBookmarks(editor)); - - return; - } - - // Update bookmarks. - const timer = setTimeout( - () => requestIdleCallback(() => setInitialBookmarks(getBookmarks(editor)), { timeout: 200 }), - 500, - ); - - return () => clearTimeout(timer); - }, [editor, editor.children, setInitialBookmarks]); - - const { positionedItems, maxCount } = useMemo<{ + interface Positioned { positionedItems: PositionedItem[]; maxCount: number; - }>(() => { - const bookmarks = Object.entries(bookmarksMap).map(([key, value]) => ({ + } + + const { positionedItems, maxCount } = useMemo(() => { + const bookmarks = bookmarksList.map(([key, value]) => ({ id: key, nodes: value, type: ItemType.BOOKMARK, })); - return getPositionedItems(editor, bookmarks, sheetRef); - }, [bookmarksMap, editor, sheetRef]); + return getPositionedItems(editorRef, bookmarks, sheetRef); + }, [bookmarksList, editorRef, sheetRef]); const onDelete = useCallback( (id: string) => { pushEvent('remove-bookmark', 'smart-editor'); - setNodes(editor, { [id]: undefined }, { match: (n) => id in n, mode: 'lowest', at: [] }); - removeBookmark(id); + setNodes(editorRef, { [id]: undefined }, { match: (n) => id in n, mode: 'lowest', at: [] }); }, - [editor, removeBookmark], + [editorRef], ); if (positionedItems.length === 0) { diff --git a/frontend/src/components/smart-editor/bookmarks/use-bookmarks.ts b/frontend/src/components/smart-editor/bookmarks/use-bookmarks.ts new file mode 100644 index 000000000..2969b5682 --- /dev/null +++ b/frontend/src/components/smart-editor/bookmarks/use-bookmarks.ts @@ -0,0 +1,31 @@ +import { isText } from '@udecode/plate-common'; +import { BOOKMARK_PREFIX } from '@app/components/smart-editor/constants'; +import { RichText, useMyPlateEditorState } from '@app/plate/types'; + +export const useBookmarks = (): [string, RichText[]][] => { + const editor = useMyPlateEditorState(); + + const bookmarkEntries = editor.nodes({ + match: (n) => isText(n) && Object.keys(n).some((k) => k.startsWith(BOOKMARK_PREFIX)), + at: [], + }); + + const bookmarkMap: Map = new Map(); + + for (const [node] of bookmarkEntries) { + const keys = Object.keys(node).filter((k) => k.startsWith(BOOKMARK_PREFIX)); + + for (const key of keys) { + const existing = bookmarkMap.get(key); + bookmarkMap.set(key, existing !== undefined ? [...existing, node] : [node]); + } + } + + if (bookmarkMap.size === 0) { + return EMPTY_BOOKMARKS; + } + + return [...bookmarkMap.entries()]; +}; + +const EMPTY_BOOKMARKS: [string, RichText[]][] = []; diff --git a/frontend/src/components/smart-editor/comments/positioned-comments.tsx b/frontend/src/components/smart-editor/comments/positioned-comments.tsx index a903de9fd..c70cf0ca0 100644 --- a/frontend/src/components/smart-editor/comments/positioned-comments.tsx +++ b/frontend/src/components/smart-editor/comments/positioned-comments.tsx @@ -9,7 +9,7 @@ import { } from '@app/components/smart-editor/functions/get-positioned-items'; import { EDITOR_SCALE_CSS_VAR } from '@app/components/smart-editor/hooks/use-scale'; import { BASE_FONT_SIZE } from '@app/plate/components/get-scaled-em'; -import { useMyPlateEditorRef } from '@app/plate/types'; +import { useMyPlateEditorState } from '@app/plate/types'; import { useThreads } from '../comments/use-threads'; import { ExpandableThread } from './expandable-thread'; @@ -22,7 +22,7 @@ const EMPTY_LIST: PositionedItem[] = []; export const PositionedComments = () => { const { attached, orphans } = useThreads(); const { sheetRef, showAnnotationsAtOrigin } = useContext(SmartEditorContext); - const editor = useMyPlateEditorRef(); + const editor = useMyPlateEditorState(); const { positionedItems, maxCount } = useMemo<{ positionedItems: PositionedItem[]; diff --git a/frontend/src/components/smart-editor/comments/use-annotations-counts.ts b/frontend/src/components/smart-editor/comments/use-annotations-counts.ts index 28c3546b0..f37a8a1f6 100644 --- a/frontend/src/components/smart-editor/comments/use-annotations-counts.ts +++ b/frontend/src/components/smart-editor/comments/use-annotations-counts.ts @@ -1,12 +1,12 @@ -import { useContext, useMemo } from 'react'; +import { useMemo } from 'react'; +import { useBookmarks } from '@app/components/smart-editor/bookmarks/use-bookmarks'; import { useThreads } from '@app/components/smart-editor/comments/use-threads'; -import { SmartEditorContext } from '@app/components/smart-editor/context'; export const useAnnotationsCounts = () => { const { attached, orphans } = useThreads(); - const { bookmarksMap } = useContext(SmartEditorContext); + const bookmarksList = useBookmarks(); - const bookmarksCount = useMemo(() => Object.keys(bookmarksMap).length, [bookmarksMap]); + const bookmarksCount = useMemo(() => bookmarksList.length, [bookmarksList]); return { attached: attached.length, diff --git a/frontend/src/components/smart-editor/context.tsx b/frontend/src/components/smart-editor/context.tsx index 759cfc13e..fca8d2596 100644 --- a/frontend/src/components/smart-editor/context.tsx +++ b/frontend/src/components/smart-editor/context.tsx @@ -4,7 +4,6 @@ import { useSmartEditorAnnotationsAtOrigin, useSmartEditorGodeFormuleringerOpen, } from '@app/hooks/settings/use-setting'; -import { RichText } from '@app/plate/types'; import { DistribusjonsType, ISmartDocument } from '@app/types/documents/documents'; import { TemplateIdEnum } from '@app/types/smart-editor/template-enums'; @@ -18,10 +17,6 @@ interface ISmartEditorContext extends Pick void; - bookmarksMap: Record; - addBookmark: (bookmarkId: string, richTexts: RichText[]) => void; - removeBookmark: (bookmarkId: string) => void; - setInitialBookmarks: (bookmarks: Record) => void; showAnnotationsAtOrigin: boolean; setShowAnnotationsAtOrigin: (show: boolean) => void; sheetRef: HTMLDivElement | null; @@ -38,10 +33,6 @@ export const SmartEditorContext = createContext({ documentId: null, focusedThreadId: null, setFocusedThreadId: noop, - bookmarksMap: {}, - addBookmark: noop, - removeBookmark: noop, - setInitialBookmarks: noop, showAnnotationsAtOrigin: false, setShowAnnotationsAtOrigin: noop, sheetRef: null, @@ -50,30 +41,19 @@ export const SmartEditorContext = createContext({ interface Props { children: React.ReactNode; - editor: ISmartDocument; + smartDocument: ISmartDocument; } -export const SmartEditorContextComponent = ({ children, editor }: Props) => { - const { dokumentTypeId, templateId, id } = editor; +export const SmartEditorContextComponent = ({ children, smartDocument }: Props) => { + const { dokumentTypeId, templateId, id } = smartDocument; const { value: showGodeFormuleringer = false, setValue: setShowGodeFormuleringer } = useSmartEditorGodeFormuleringerOpen(); const [newCommentSelection, setNewCommentSelection] = useState(null); const [focusedThreadId, setFocusedThreadId] = useState(null); - const [bookmarksMap, setBookmarksMap] = useState>({}); const { value: showAnnotationsAtOrigin = false, setValue: setShowAnnotationsAtOrigin } = useSmartEditorAnnotationsAtOrigin(); const [sheetRef, setSheetRef] = useState(null); - const addBookmark = (bookmarkId: string, richTexts: RichText[]) => - setBookmarksMap((prev) => ({ ...prev, [bookmarkId]: richTexts })); - - const removeBookmark = (bookmarkId: string) => - setBookmarksMap((prev) => { - delete prev[bookmarkId]; - - return { ...prev }; - }); - return ( { documentId: id, focusedThreadId, setFocusedThreadId, - bookmarksMap, - addBookmark, - removeBookmark, - setInitialBookmarks: setBookmarksMap, showAnnotationsAtOrigin, setShowAnnotationsAtOrigin, sheetRef, diff --git a/frontend/src/components/smart-editor/functions/get-positioned-items.ts b/frontend/src/components/smart-editor/functions/get-positioned-items.ts index ce9773fd2..02e868eb4 100644 --- a/frontend/src/components/smart-editor/functions/get-positioned-items.ts +++ b/frontend/src/components/smart-editor/functions/get-positioned-items.ts @@ -1,4 +1,4 @@ -import { findNode } from '@udecode/plate-common'; +import { findNode, isText } from '@udecode/plate-common'; import { FocusedComment } from '@app/components/smart-editor/comments/use-threads'; import { COMMENT_PREFIX } from '@app/components/smart-editor/constants'; import { calculateRangePosition } from '@app/plate/functions/range-position'; @@ -26,27 +26,30 @@ export interface PositionedItem { floorIndex: number; } -const PREFIX_MAP = { - [ItemType.THREAD]: COMMENT_PREFIX, - [ItemType.BOOKMARK]: '', -}; +interface Positioned { + positionedItems: PositionedItem[]; + maxCount: number; +} export const getPositionedItems = ( editor: RichTextEditor, list: T[], ref: HTMLElement | null, -): { positionedItems: PositionedItem[]; maxCount: number } => { +): Positioned => { const positionedItems = new Array>(list.length); const { length } = list; let maxCount = 0; for (let i = 0; i < length; i++) { - const item = list[i]!; + const item = list[i]; - const leafEntry = findNode(editor, { - at: [], - match: (n) => Object.keys(n).some((k) => k.startsWith(`${PREFIX_MAP[item.type]}${item.id}`)), - }); + if (item === undefined) { + continue; + } + + const mark = item.type === ItemType.THREAD ? `${COMMENT_PREFIX}${item.id}` : item.id; + + const leafEntry = findNode(editor, { at: [], match: (n) => isText(n) && Object.hasOwn(n, mark) }); if (leafEntry === undefined) { continue; @@ -68,11 +71,7 @@ export const getPositionedItems = ( maxCount = floorIndex + 1; } - if (item.type === ItemType.BOOKMARK) { - positionedItems[i] = { data: item, top, floorIndex }; - } else { - positionedItems[i] = { data: item, top, floorIndex }; - } + positionedItems[i] = { data: item, top, floorIndex }; } return { positionedItems, maxCount }; diff --git a/frontend/src/components/smart-editor/gode-formuleringer/use-translated-formuleringer.ts b/frontend/src/components/smart-editor/gode-formuleringer/use-translated-formuleringer.ts index bfee7d582..7f43c39c1 100644 --- a/frontend/src/components/smart-editor/gode-formuleringer/use-translated-formuleringer.ts +++ b/frontend/src/components/smart-editor/gode-formuleringer/use-translated-formuleringer.ts @@ -1,14 +1,13 @@ import { skipToken } from '@reduxjs/toolkit/query'; import { useEffect, useState } from 'react'; +import { useGodFormuleringerQuery } from '@app/components/smart-editor/hooks/use-query'; import { NONE, NONE_TYPE } from '@app/components/smart-editor-texts/types'; import { useSmartEditorLanguage } from '@app/hooks/use-smart-editor-language'; import { TemplateSections } from '@app/plate/template-sections'; import { useLazyGetConsumerTextsQuery } from '@app/redux-api/texts/consumer'; -import { GOD_FORMULERING_TYPE } from '@app/types/common-text-types'; import { TemplateIdEnum } from '@app/types/smart-editor/template-enums'; import { NonNullableGodFormulering, isNonNullGodFormulering } from '@app/types/texts/consumer'; import { LANGUAGES } from '@app/types/texts/language'; -import { useQuery } from '../hooks/use-query'; interface State { isLoading: boolean; @@ -21,11 +20,7 @@ export const useTranslatedFormuleringer = ( ): State => { const [getTexts] = useLazyGetConsumerTextsQuery(); const primaryLanguage = useSmartEditorLanguage(); - const baseQuery = useQuery({ - textType: GOD_FORMULERING_TYPE, - templateId, - section: section === NONE ? undefined : section, - }); + const baseQuery = useGodFormuleringerQuery(templateId, section === NONE ? undefined : section); const [isLoading, setIsLoading] = useState(true); const [texts, setTexts] = useState([]); diff --git a/frontend/src/components/smart-editor/history/history-editor.tsx b/frontend/src/components/smart-editor/history/history-editor.tsx index dc7f528ff..0f63be455 100644 --- a/frontend/src/components/smart-editor/history/history-editor.tsx +++ b/frontend/src/components/smart-editor/history/history-editor.tsx @@ -1,15 +1,20 @@ import { Button } from '@navikt/ds-react'; -import { Plate, insertNodes, removeNodes, withoutNormalizing, withoutSavingHistory } from '@udecode/plate-common'; -import { memo, useEffect, useMemo } from 'react'; +import { + Plate, + insertNodes, + removeNodes, + resetEditorChildren, + withoutNormalizing, + withoutSavingHistory, +} from '@udecode/plate-common'; +import { memo, useContext, useEffect } from 'react'; import { styled } from 'styled-components'; -import { getIsRolAnswers, getIsRolQuestions } from '@app/components/documents/new-documents/helpers'; +import { SmartEditorContext } from '@app/components/smart-editor/context'; +import { useCanManageDocument } from '@app/components/smart-editor/hooks/use-can-edit-document'; import { EDITOR_SCALE_CSS_VAR } from '@app/components/smart-editor/hooks/use-scale'; import { ErrorComponent } from '@app/components/smart-editor-texts/error-component'; import { ErrorBoundary } from '@app/error-boundary/error-boundary'; import { areDescendantsEqual } from '@app/functions/are-descendants-equal'; -import { useOppgave } from '@app/hooks/oppgavebehandling/use-oppgave'; -import { useIsRol } from '@app/hooks/use-is-rol'; -import { useIsSaksbehandler } from '@app/hooks/use-is-saksbehandler'; import { useSmartEditorSpellCheckLanguage } from '@app/hooks/use-smart-editor-language'; import { pushEvent } from '@app/observability'; import { PlateEditor } from '@app/plate/plate-editor'; @@ -17,8 +22,6 @@ import { saksbehandlerPlugins } from '@app/plate/plugins/plugin-sets/saksbehandl import { Sheet } from '@app/plate/sheet'; import { EditorValue, RichTextEditor, useMyPlateEditorRef } from '@app/plate/types'; import { ISmartDocument } from '@app/types/documents/documents'; -import { SaksTypeEnum } from '@app/types/kodeverk'; -import { FlowState } from '@app/types/oppgave-common'; interface Props { versionId: number; @@ -29,42 +32,8 @@ interface Props { export const HistoryEditor = memo( ({ smartDocument, version, versionId }: Props) => { const mainEditor = useMyPlateEditorRef(smartDocument.id); - const { data: oppgave } = useOppgave(); - const isSaksbehandler = useIsSaksbehandler(); - const isRol = useIsRol(); - - const rolCanRestore = - isRol && - oppgave !== undefined && - (oppgave.typeId === SaksTypeEnum.KLAGE || oppgave.typeId === SaksTypeEnum.ANKE) && - oppgave.rol.flowState !== FlowState.SENT && - getIsRolAnswers(smartDocument); - - const saksbehandlerCanRestore = useMemo(() => { - if (!isSaksbehandler || oppgave === undefined) { - return false; - } - - if (oppgave.medunderskriver.flowState === FlowState.SENT) { - return false; - } - - if (getIsRolAnswers(smartDocument)) { - return false; - } - - if (getIsRolQuestions(smartDocument)) { - if (oppgave.typeId === SaksTypeEnum.KLAGE || oppgave.typeId === SaksTypeEnum.ANKE) { - if (oppgave.rol.flowState === FlowState.SENT) { - return false; - } - } - } - - return true; - }, [isSaksbehandler, oppgave, smartDocument]); - - const disableRestore = !saksbehandlerCanRestore && !rolCanRestore; + const { templateId } = useContext(SmartEditorContext); + const canManage = useCanManageDocument(templateId); const id = `${smartDocument.id}-${versionId}`; @@ -80,7 +49,7 @@ export const HistoryEditor = memo( restore(mainEditor, version); }} size="small" - disabled={disableRestore} + disabled={!canManage} > Gjenopprett denne versjonen @@ -106,10 +75,10 @@ interface HistoryContentProps { } const HistoryContent = ({ id, version }: HistoryContentProps) => { - const edior = useMyPlateEditorRef(id); + const editor = useMyPlateEditorRef(id); const lang = useSmartEditorSpellCheckLanguage(); - useEffect(() => restore(edior, version), [edior, version]); + useEffect(() => restore(editor, version), [editor, version]); return ( @@ -121,10 +90,7 @@ const HistoryContent = ({ id, version }: HistoryContentProps) => { const restore = (editor: RichTextEditor, content: EditorValue) => { withoutNormalizing(editor, () => { withoutSavingHistory(editor, () => { - for (let i = editor.children.length - 1; i >= 0; i--) { - removeNodes(editor, { at: [i] }); - } - + resetEditorChildren(editor); insertNodes(editor, content, { at: [0] }); // Remove empty paragraph that is added automatically diff --git a/frontend/src/components/smart-editor/hooks/use-can-edit-document.test.ts b/frontend/src/components/smart-editor/hooks/use-can-edit-document.test.ts new file mode 100644 index 000000000..f6ffcba8a --- /dev/null +++ b/frontend/src/components/smart-editor/hooks/use-can-edit-document.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from 'bun:test'; +import { canEditDocument } from '@app/components/smart-editor/hooks/use-can-edit-document'; +import { IUserData } from '@app/types/bruker'; +import { SaksTypeEnum } from '@app/types/kodeverk'; +import { FlowState } from '@app/types/oppgave-common'; +import { IOppgavebehandling } from '@app/types/oppgavebehandling/oppgavebehandling'; +import { TemplateIdEnum } from '@app/types/smart-editor/template-enums'; + +const createOppgave = (muFlowState: FlowState, rolFlowState: FlowState): IOppgavebehandling => + ({ + typeId: SaksTypeEnum.KLAGE, + saksbehandler: { navIdent: 'saksbehandler' }, + rol: { flowState: rolFlowState, employee: { navIdent: 'rol' } }, + medunderskriver: { flowState: muFlowState, employee: { navIdent: 'mu' } }, + }) as IOppgavebehandling; + +const CASES_WITHOUT_EXPECT = [ + [FlowState.NOT_SENT, FlowState.NOT_SENT], + [FlowState.NOT_SENT, FlowState.SENT], + [FlowState.NOT_SENT, FlowState.RETURNED], + [FlowState.SENT, FlowState.NOT_SENT], + [FlowState.SENT, FlowState.SENT], + [FlowState.SENT, FlowState.RETURNED], + [FlowState.RETURNED, FlowState.NOT_SENT], + [FlowState.RETURNED, FlowState.SENT], + [FlowState.RETURNED, FlowState.RETURNED], +]; + +describe('canEditDocument', () => { + describe('Saksbehandler', () => { + const user = { navIdent: 'saksbehandler' } as IUserData; + + describe('ROL answers', () => { + it.each(CASES_WITHOUT_EXPECT)('%#. should never allow editing', (mu, rol) => { + expect(canEditDocument(TemplateIdEnum.ROL_ANSWERS, createOppgave(mu, rol), user)).toBe(false); + }); + }); + + describe('ROL questions', () => { + const cases = [ + { mu: FlowState.NOT_SENT, rol: FlowState.NOT_SENT, expected: true }, + { mu: FlowState.NOT_SENT, rol: FlowState.SENT, expected: false }, + { mu: FlowState.NOT_SENT, rol: FlowState.RETURNED, expected: true }, + { mu: FlowState.SENT, rol: FlowState.NOT_SENT, expected: true }, + { mu: FlowState.SENT, rol: FlowState.SENT, expected: false }, + { mu: FlowState.SENT, rol: FlowState.RETURNED, expected: true }, + { mu: FlowState.RETURNED, rol: FlowState.NOT_SENT, expected: true }, + { mu: FlowState.RETURNED, rol: FlowState.SENT, expected: false }, + { mu: FlowState.RETURNED, rol: FlowState.RETURNED, expected: true }, + ]; + + it.each(cases)('%#. should not allow editing if ROL flow state is sent', ({ mu, rol, expected }) => { + expect(canEditDocument(TemplateIdEnum.ROL_QUESTIONS, createOppgave(mu, rol), user)).toBe(expected); + }); + }); + + describe('Other templates', () => { + const cases = [ + { mu: FlowState.NOT_SENT, rol: FlowState.NOT_SENT, expected: true }, + { mu: FlowState.NOT_SENT, rol: FlowState.SENT, expected: true }, + { mu: FlowState.NOT_SENT, rol: FlowState.RETURNED, expected: true }, + { mu: FlowState.SENT, rol: FlowState.NOT_SENT, expected: false }, + { mu: FlowState.SENT, rol: FlowState.SENT, expected: false }, + { mu: FlowState.SENT, rol: FlowState.RETURNED, expected: false }, + { mu: FlowState.RETURNED, rol: FlowState.NOT_SENT, expected: true }, + { mu: FlowState.RETURNED, rol: FlowState.SENT, expected: true }, + { mu: FlowState.RETURNED, rol: FlowState.RETURNED, expected: true }, + ]; + + it.each(cases)('%#. should not allow editing if MU flow state is sent', ({ mu, rol, expected }) => { + expect(canEditDocument(TemplateIdEnum.KLAGEVEDTAK_V2, createOppgave(mu, rol), user)).toBe(expected); + }); + }); + }); + + describe('Medunderskriver', () => { + const user = { navIdent: 'mu' } as IUserData; + + describe('ROL answers', () => { + it.each(CASES_WITHOUT_EXPECT)('%#. should never allow editing', (mu, rol) => { + expect(canEditDocument(TemplateIdEnum.ROL_ANSWERS, createOppgave(mu, rol), user)).toBe(false); + }); + }); + + describe('Other templates', () => { + const cases = [ + { mu: FlowState.NOT_SENT, rol: FlowState.NOT_SENT, expected: false }, + { mu: FlowState.NOT_SENT, rol: FlowState.SENT, expected: false }, + { mu: FlowState.NOT_SENT, rol: FlowState.RETURNED, expected: false }, + { mu: FlowState.SENT, rol: FlowState.NOT_SENT, expected: true }, + { mu: FlowState.SENT, rol: FlowState.SENT, expected: true }, + { mu: FlowState.SENT, rol: FlowState.RETURNED, expected: true }, + { mu: FlowState.RETURNED, rol: FlowState.NOT_SENT, expected: false }, + { mu: FlowState.RETURNED, rol: FlowState.SENT, expected: false }, + { mu: FlowState.RETURNED, rol: FlowState.RETURNED, expected: false }, + ]; + + it.each(cases)('%#. should allow editing if MU flow state is sent', ({ mu, rol, expected }) => { + expect(canEditDocument(TemplateIdEnum.KLAGEVEDTAK_V2, createOppgave(mu, rol), user)).toBe(expected); + }); + }); + }); + + describe('ROL', () => { + const user = { navIdent: 'rol' } as IUserData; + + describe('ROL answers', () => { + const cases = [ + { mu: FlowState.NOT_SENT, rol: FlowState.NOT_SENT, expected: false }, + { mu: FlowState.NOT_SENT, rol: FlowState.SENT, expected: true }, + { mu: FlowState.NOT_SENT, rol: FlowState.RETURNED, expected: false }, + { mu: FlowState.SENT, rol: FlowState.NOT_SENT, expected: false }, + { mu: FlowState.SENT, rol: FlowState.SENT, expected: true }, + { mu: FlowState.SENT, rol: FlowState.RETURNED, expected: false }, + { mu: FlowState.RETURNED, rol: FlowState.NOT_SENT, expected: false }, + { mu: FlowState.RETURNED, rol: FlowState.SENT, expected: true }, + { mu: FlowState.RETURNED, rol: FlowState.RETURNED, expected: false }, + ]; + + it.each(cases)('%#. should allow editing if ROL flow state is sent', ({ mu, rol, expected }) => { + expect(canEditDocument(TemplateIdEnum.ROL_ANSWERS, createOppgave(mu, rol), user)).toBe(expected); + }); + }); + + describe('Other templates', () => { + it.each(CASES_WITHOUT_EXPECT)('%#. should never allow editing', (mu, rol) => { + expect(canEditDocument(TemplateIdEnum.KLAGEVEDTAK_V2, createOppgave(mu, rol), user)).toBe(false); + }); + }); + }); +}); diff --git a/frontend/src/components/smart-editor/hooks/use-can-edit-document.ts b/frontend/src/components/smart-editor/hooks/use-can-edit-document.ts index abafa8fef..644ccb076 100644 --- a/frontend/src/components/smart-editor/hooks/use-can-edit-document.ts +++ b/frontend/src/components/smart-editor/hooks/use-can-edit-document.ts @@ -1,45 +1,88 @@ import { useContext, useMemo } from 'react'; import { StaticDataContext } from '@app/components/app/static-data-context'; import { useOppgave } from '@app/hooks/oppgavebehandling/use-oppgave'; +import { IUserData } from '@app/types/bruker'; import { SaksTypeEnum } from '@app/types/kodeverk'; import { FlowState } from '@app/types/oppgave-common'; +import { IOppgavebehandling } from '@app/types/oppgavebehandling/oppgavebehandling'; import { TemplateIdEnum } from '@app/types/smart-editor/template-enums'; -export const useCanEditDocument = (templateId: TemplateIdEnum): boolean => { - const { data: oppgave, isLoading: oppgaveIsLoading, isFetching: oppgaveIsFetching } = useOppgave(); +export const useCanManageDocument = (templateId: TemplateIdEnum): boolean => { + const { data: oppgave, isSuccess } = useOppgave(); const { user } = useContext(StaticDataContext); - return useMemo(() => { - if (oppgaveIsLoading || oppgaveIsFetching) { - return false; - } + return useMemo( + () => isSuccess && canManageDocument(templateId, oppgave, user), + [oppgave, isSuccess, templateId, user], + ); +}; - if (oppgave === undefined) { - return false; - } +const canManageDocument = (templateId: TemplateIdEnum, oppgave: IOppgavebehandling, user: IUserData): boolean => { + if ( + (oppgave.typeId === SaksTypeEnum.KLAGE || oppgave.typeId === SaksTypeEnum.ANKE) && + oppgave.rol?.flowState === FlowState.SENT && + templateId === TemplateIdEnum.ROL_QUESTIONS + ) { + // No one can edit ROL questions after they have been sent. + return false; + } - if (oppgave.medunderskriver.flowState === FlowState.SENT) { - return oppgave.medunderskriver.employee?.navIdent === user.navIdent; - } + if (isRol(oppgave, user)) { + return rolCanEdit(templateId, oppgave); + } - if ( - (oppgave.typeId === SaksTypeEnum.KLAGE || oppgave.typeId === SaksTypeEnum.ANKE) && - oppgave.rol?.flowState === FlowState.SENT && - templateId === TemplateIdEnum.ROL_QUESTIONS - ) { - return false; - } + if (isMu(oppgave, user)) { + return false; + } + + return saksbehandlerCanEdit(templateId, oppgave, user); +}; - if ( +const saksbehandlerCanEdit = (templateId: TemplateIdEnum, oppgave: IOppgavebehandling, user: IUserData): boolean => { + if (oppgave.saksbehandler?.navIdent !== user.navIdent) { + return false; + } + + if (templateId === TemplateIdEnum.ROL_ANSWERS) { + return false; + } + + // When behandling is sent to ROL, saksbehandler can edit everything except questions. + if (templateId === TemplateIdEnum.ROL_QUESTIONS) { + return ( (oppgave.typeId === SaksTypeEnum.KLAGE || oppgave.typeId === SaksTypeEnum.ANKE) && - oppgave.rol.employee !== null && - oppgave.rol.flowState === FlowState.SENT && - templateId === TemplateIdEnum.ROL_ANSWERS && - oppgave.rol.employee.navIdent === user.navIdent - ) { - return true; - } - - return oppgave.saksbehandler?.navIdent === user.navIdent; - }, [oppgave, oppgaveIsFetching, oppgaveIsLoading, templateId, user]); + oppgave.rol?.flowState !== FlowState.SENT + ); + } + + return oppgave.medunderskriver?.flowState !== FlowState.SENT; +}; + +const isMu = (oppgave: IOppgavebehandling, user: IUserData): boolean => + oppgave.medunderskriver?.employee?.navIdent === user.navIdent; + +const isRol = (oppgave: IOppgavebehandling, user: IUserData): boolean => + (oppgave.typeId === SaksTypeEnum.KLAGE || oppgave.typeId === SaksTypeEnum.ANKE) && + oppgave.rol.employee?.navIdent === user.navIdent; + +// Only ROL can edit answers after they have been sent. +const rolCanEdit = (templateId: TemplateIdEnum, oppgave: IOppgavebehandling): boolean => + (oppgave.typeId === SaksTypeEnum.KLAGE || oppgave.typeId === SaksTypeEnum.ANKE) && + oppgave.rol.flowState === FlowState.SENT && + templateId === TemplateIdEnum.ROL_ANSWERS; + +export const useCanEditDocument = (templateId: TemplateIdEnum): boolean => { + const { data: oppgave, isSuccess } = useOppgave(); + const { user } = useContext(StaticDataContext); + + return useMemo( + () => isSuccess && canEditDocument(templateId, oppgave, user), + [oppgave, isSuccess, templateId, user], + ); }; + +const muCanEdit = (templateId: TemplateIdEnum, oppgave: IOppgavebehandling): boolean => + oppgave.medunderskriver.flowState === FlowState.SENT && templateId !== TemplateIdEnum.ROL_ANSWERS; + +export const canEditDocument = (templateId: TemplateIdEnum, oppgave: IOppgavebehandling, user: IUserData): boolean => + canManageDocument(templateId, oppgave, user) || (isMu(oppgave, user) && muCanEdit(templateId, oppgave)); diff --git a/frontend/src/components/smart-editor/hooks/use-query.ts b/frontend/src/components/smart-editor/hooks/use-query.ts index edf3fdd96..70db678ff 100644 --- a/frontend/src/components/smart-editor/hooks/use-query.ts +++ b/frontend/src/components/smart-editor/hooks/use-query.ts @@ -1,22 +1,37 @@ -import { skipToken } from '@reduxjs/toolkit/query'; +import { SkipToken, skipToken } from '@reduxjs/toolkit/query'; import { useContext, useMemo } from 'react'; import { StaticDataContext } from '@app/components/app/static-data-context'; import { GLOBAL, LIST_DELIMITER, SET_DELIMITER } from '@app/components/smart-editor-texts/types'; import { useOppgave } from '@app/hooks/oppgavebehandling/use-oppgave'; import { useSmartEditorLanguage } from '@app/hooks/use-smart-editor-language'; -import { IGetConsumerTextsParams, IGetTextsParams, TextTypes } from '@app/types/common-text-types'; +import { TemplateSections } from '@app/plate/template-sections'; +import { + GOD_FORMULERING_TYPE, + IGetConsumerGodFormuleringParams, + IGetConsumerHeaderFooterParams, + IGetConsumerMaltekstseksjonerParams, + IGetConsumerRegelverkParams, + IGetConsumerTextsParams, + PlainTextTypes, + REGELVERK_TYPE, + TextTypes, +} from '@app/types/common-text-types'; import { UtfallEnum } from '@app/types/kodeverk'; import { TemplateIdEnum } from '@app/types/smart-editor/template-enums'; import { Language, UNTRANSLATED } from '@app/types/texts/language'; interface Params { - textType: TextTypes; templateId?: TemplateIdEnum; section?: string; + includeEnhet?: boolean; + textType: TextTypes; language?: Language | typeof UNTRANSLATED; } -export const useQuery = ({ textType, templateId, section, language }: Params) => { +/** Deprecated + * @deprecated Remove when no longer in use by legacy (redigerbar) maltekst. + */ +export const useQuery = ({ textType, templateId, section, language, includeEnhet = false }: Params) => { const { data: oppgave, isLoading } = useOppgave(); const { user } = useContext(StaticDataContext); const defaultLanguagae = useSmartEditorLanguage(); @@ -36,14 +51,103 @@ export const useQuery = ({ textType, templateId, section, language }: Params) => const query: IGetConsumerTextsParams = { ytelseHjemmelIdList: getYtelseHjemmelList(oppgave.ytelseId, oppgave.resultat.hjemmelIdSet), utfallIdList: getUtfallList(extraUtfallIdSet, utfallId), - enhetIdList: [user.ansattEnhet.id], + enhetIdList: includeEnhet ? [user.ansattEnhet.id] : undefined, templateSectionIdList: templateSectionList, textType, language: language ?? defaultLanguagae, }; return query; - }, [isLoading, oppgave, templateId, section, user.ansattEnhet.id, textType, language, defaultLanguagae]); + }, [ + isLoading, + oppgave, + templateId, + section, + includeEnhet, + user.ansattEnhet.id, + textType, + language, + defaultLanguagae, + ]); +}; + +export const useHeaderFooterQuery = (textType: PlainTextTypes): IGetConsumerHeaderFooterParams | SkipToken => { + const { user } = useContext(StaticDataContext); + const language = useSmartEditorLanguage(); + + return useMemo( + () => ({ textType, enhetIdList: [user.ansattEnhet.id], language }), + [textType, user.ansattEnhet.id, language], + ); +}; + +export const useMaltekstseksjonQuery = ( + templateId: TemplateIdEnum, + section: TemplateSections, +): IGetConsumerMaltekstseksjonerParams | SkipToken => { + const { data: oppgave, isLoading } = useOppgave(); + + return useMemo(() => { + if (isLoading || oppgave === undefined) { + return skipToken; + } + + const { extraUtfallIdSet, utfallId } = oppgave.resultat; + + const templateSectionList = + templateId !== undefined && section !== undefined + ? [`${templateId}${LIST_DELIMITER}${section}`, `${GLOBAL}${LIST_DELIMITER}${section}`] + : []; + + return { + ytelseHjemmelIdList: getYtelseHjemmelList(oppgave.ytelseId, oppgave.resultat.hjemmelIdSet), + utfallIdList: getUtfallList(extraUtfallIdSet, utfallId), + templateSectionIdList: templateSectionList, + }; + }, [isLoading, oppgave, templateId, section]); +}; + +export const useGodFormuleringerQuery = ( + templateId: TemplateIdEnum | undefined, + section: TemplateSections | undefined, +): IGetConsumerGodFormuleringParams | SkipToken => { + const { data: oppgave, isLoading } = useOppgave(); + const language = useSmartEditorLanguage(); + + return useMemo(() => { + if (isLoading || oppgave === undefined) { + return skipToken; + } + + const { extraUtfallIdSet, utfallId } = oppgave.resultat; + + return { + ytelseHjemmelIdList: getYtelseHjemmelList(oppgave.ytelseId, oppgave.resultat.hjemmelIdSet), + utfallIdList: getUtfallList(extraUtfallIdSet, utfallId), + templateSectionIdList: [`${templateId}${LIST_DELIMITER}${section}`, `${GLOBAL}${LIST_DELIMITER}${section}`], + textType: GOD_FORMULERING_TYPE, + language, + }; + }, [isLoading, oppgave, templateId, section, language]); +}; + +export const useRegelverkQuery = (): IGetConsumerRegelverkParams | SkipToken => { + const { data: oppgave, isLoading } = useOppgave(); + + return useMemo(() => { + if (isLoading || oppgave === undefined) { + return skipToken; + } + + const { extraUtfallIdSet, utfallId } = oppgave.resultat; + + return { + ytelseHjemmelIdList: getYtelseHjemmelList(oppgave.ytelseId, oppgave.resultat.hjemmelIdSet), + utfallIdList: getUtfallList(extraUtfallIdSet, utfallId), + textType: REGELVERK_TYPE, + language: UNTRANSLATED, + }; + }, [isLoading, oppgave]); }; const getYtelseHjemmelList = (ytelse: string, hjemmelList: string[]): string[] => { @@ -56,19 +160,12 @@ const getYtelseHjemmelList = (ytelse: string, hjemmelList: string[]): string[] = return result; }; -const getUtfallList = ( - extraUtfallIdSet: UtfallEnum[], - utfallId: UtfallEnum | null, -): IGetTextsParams['utfallIdList'] => { +const getUtfallList = (extraUtfallIdSet: UtfallEnum[], utfallId: UtfallEnum | null): string => { const utfallSet: Set = utfallId === null ? new Set([]) : new Set([utfallId]); for (const item of extraUtfallIdSet) { utfallSet.add(item); } - if (utfallSet.size === 0) { - return ''; - } - - return Array.from(utfallSet).sort().join(SET_DELIMITER); + return utfallSet.size === 0 ? '' : Array.from(utfallSet).sort().join(SET_DELIMITER); }; diff --git a/frontend/src/components/smart-editor/new-document/generated-icon.tsx b/frontend/src/components/smart-editor/new-document/generated-icon.tsx index a3c258ed9..4defc5ad5 100644 --- a/frontend/src/components/smart-editor/new-document/generated-icon.tsx +++ b/frontend/src/components/smart-editor/new-document/generated-icon.tsx @@ -18,6 +18,7 @@ import { ELEMENT_SIGNATURE, } from '@app/plate/plugins/element-types'; import { ISmartEditorTemplate } from '@app/types/smart-editor/smart-editor'; +import { BaseParagraphPlugin } from '@udecode/plate-common'; interface GeneratedIconProps { template: ISmartEditorTemplate; @@ -54,7 +55,7 @@ export const GeneratedIcon = ({ template }: GeneratedIconProps) => { y += 5 + SPACING; break; } - case ELEMENT_PARAGRAPH: { + case BaseParagraphPlugin.key: { rects.push(r({ type, key: i, width: 120, y })); y += 5 + SPACING; break; diff --git a/frontend/src/components/smart-editor/new-document/new-document.tsx b/frontend/src/components/smart-editor/new-document/new-document.tsx index 17ffbce57..e97d58aaa 100644 --- a/frontend/src/components/smart-editor/new-document/new-document.tsx +++ b/frontend/src/components/smart-editor/new-document/new-document.tsx @@ -9,8 +9,13 @@ import { useIsFeilregistrert } from '@app/hooks/use-is-feilregistrert'; import { useIsRol } from '@app/hooks/use-is-rol'; import { useIsSaksbehandler } from '@app/hooks/use-is-saksbehandler'; import { GENERELT_BREV_TEMPLATE, NOTAT_TEMPLATE } from '@app/plate/templates/simple-templates'; -import { ANKE_I_TRYGDERETTEN_TEMPLATES, ANKE_TEMPLATES, KLAGE_TEMPLATES } from '@app/plate/templates/templates'; -import { useCreateSmartDocumentMutation } from '@app/redux-api/oppgaver/mutations/smart-document'; +import { + ANKE_I_TRYGDERETTEN_TEMPLATES, + ANKE_TEMPLATES, + BEHANDLING_ETTER_TR_OPPHEVET_TEMPLATES, + KLAGE_TEMPLATES, +} from '@app/plate/templates/templates'; +import { useCreateSmartDocumentMutation } from '@app/redux-api/collaboration'; import { useGetDocumentsQuery } from '@app/redux-api/oppgaver/queries/documents'; import { Role } from '@app/types/bruker'; import { SaksTypeEnum } from '@app/types/kodeverk'; @@ -60,7 +65,9 @@ export const NewDocument = ({ onCreate }: Props) => { try { const { id } = await createSmartDocument({ - ...template, + templateId: template.templateId, + dokumentTypeId: template.dokumentTypeId, + content: template.richText, tittel, oppgaveId: oppgave.id, creatorIdent: user.navIdent, @@ -115,6 +122,8 @@ const useTemplates = (oppgave: IOppgavebehandling | undefined) => { return ANKE_TEMPLATES; case SaksTypeEnum.ANKE_I_TRYGDERETTEN: return ANKE_I_TRYGDERETTEN_TEMPLATES; + case SaksTypeEnum.BEHANDLING_ETTER_TR_OPPHEVET: + return BEHANDLING_ETTER_TR_OPPHEVET_TEMPLATES; } } diff --git a/frontend/src/components/smart-editor/tabbed-editors/cursors/cursor-colors.ts b/frontend/src/components/smart-editor/tabbed-editors/cursors/cursor-colors.ts new file mode 100644 index 000000000..29a6d6077 --- /dev/null +++ b/frontend/src/components/smart-editor/tabbed-editors/cursors/cursor-colors.ts @@ -0,0 +1,61 @@ +interface BaseColor { + red: number; + green: number; + blue: number; +} + +const MAP: Map = new Map(); + +// #C30000 +const RED: BaseColor = { red: 195, green: 0, blue: 0 }; +// #06893A +const GREEN: BaseColor = { red: 6, green: 137, blue: 58 }; +// #0067C5 +const BLUE: BaseColor = { red: 0, green: 103, blue: 197 }; +// #FF9100 +const ORANGE: BaseColor = { red: 255, green: 145, blue: 0 }; +// #634689 +const PURPLE: BaseColor = { red: 99, green: 70, blue: 137 }; + +const ALL_COLORS = [RED, GREEN, BLUE, ORANGE, PURPLE]; + +interface Colors { + caretColor: string; + selectionColor: string; +} + +export const getColors = (key: string): Colors => { + const existing = MAP.get(key); + + if (existing === undefined) { + const availableColors = getAvailableColors(); + + const randomColorIndex = Math.floor(Math.random() * availableColors.length); + const baseColor = availableColors[randomColorIndex]!; + + MAP.set(key, baseColor); + + return { + selectionColor: formatColor(baseColor, 0.2), + caretColor: formatColor(baseColor, 1), + }; + } + + return { + selectionColor: formatColor(existing, 0.2), + caretColor: formatColor(existing, 1), + }; +}; + +const getAvailableColors = (): BaseColor[] => { + const availableColors = ALL_COLORS.filter((color) => ![...MAP.values()].includes(color)); + + if (availableColors.length === 0) { + return ALL_COLORS; + } + + return availableColors; +}; + +const formatColor = (color: BaseColor, opacity: number): string => + `rgba(${color.red}, ${color.green}, ${color.blue}, ${opacity})`; diff --git a/frontend/src/components/smart-editor/tabbed-editors/cursors/cursors.tsx b/frontend/src/components/smart-editor/tabbed-editors/cursors/cursors.tsx new file mode 100644 index 000000000..acb42ee2c --- /dev/null +++ b/frontend/src/components/smart-editor/tabbed-editors/cursors/cursors.tsx @@ -0,0 +1,121 @@ +import { RelativeRange } from '@slate-yjs/core'; +import { UnknownObject, createZustandStore } from '@udecode/plate-common'; +import { CursorData, CursorProps, CursorState, useCursorOverlayPositions } from '@udecode/plate-cursor'; +import { useEffect, useMemo, useRef } from 'react'; +import { styled } from 'styled-components'; +import { getColors } from '@app/components/smart-editor/tabbed-editors/cursors/cursor-colors'; + +export interface UserCursor extends CursorData, UnknownObject { + navn: string; + navIdent: string; + tabId: string; +} + +interface YjsCursor { + selection: RelativeRange; + data: UserCursor; +} + +// eslint-disable-next-line import/no-unused-modules +export const isYjsCursor = (value: unknown): value is YjsCursor => + typeof value === 'object' && value !== null && 'selection' in value && 'data' in value; + +const Cursor = ({ caretPosition, data, disableCaret, disableSelection, selectionRects }: CursorProps) => { + const previousCaretPosition = useRef(caretPosition); + const { style, selectionStyle = style, navIdent, navn, tabId } = data ?? {}; + + const labelRef = useRef(null); + + const { caretColor, selectionColor } = useMemo(() => getColors(tabId ?? ''), [tabId]); + + // Use the previous caret position if the current caret position is null. + // This is to avoid flickering of the caret. It may lag behind a little instead. + const safeCaretPosition = caretPosition ?? previousCaretPosition.current; + + // Remember the previous caret position. + useEffect(() => { + if (caretPosition !== null) { + previousCaretPosition.current = caretPosition; + } + }, [caretPosition]); + + return ( + <> + {disableSelection === true + ? null + : selectionRects.map((selectionPosition, i) => ( + + ))} + {disableCaret === true || safeCaretPosition === null ? null : ( + + + {navn} ({navIdent}) + + + )} + + ); +}; + +const BaseCursor = styled.div` + pointer-events: none; + position: absolute; + z-index: 10; +`; + +const StyledSelection = styled(BaseCursor)``; + +const StyledCaret = styled(BaseCursor)` + width: 1px; +`; + +const CaretLabel = styled.div` + position: absolute; + bottom: 100%; + left: 0; + color: white; + font-size: 0.75em; + padding: 0.25em; + border-radius: var(--a-border-radius-medium); + border-bottom-left-radius: 0; + white-space: nowrap; +`; + +// eslint-disable-next-line import/no-unused-modules +export const cursorStore = createZustandStore('cursors')>>({}); + +interface CursorOverlayProps { + containerElement: HTMLElement; +} + +// eslint-disable-next-line import/no-unused-modules +export const CursorOverlay = ({ containerElement }: CursorOverlayProps) => { + const { useStore } = cursorStore; + const yjsCursors = useStore(); + const containerRef = useRef(containerElement); + const { cursors, refresh } = useCursorOverlayPositions({ containerRef, cursors: yjsCursors }); + + // Refresh the cursor positions if any caret position is null. + useEffect(() => { + if (cursors.some((cursor) => cursor.caretPosition === null)) { + const req = requestAnimationFrame(() => { + refresh(); + }); + + return () => { + cancelAnimationFrame(req); + }; + } + }, [cursors, refresh]); + + return ( + <> + {cursors.map((cursor) => ( + + ))} + + ); +}; diff --git a/frontend/src/components/smart-editor/tabbed-editors/editor.tsx b/frontend/src/components/smart-editor/tabbed-editors/editor.tsx index 26abcd205..0a0f7671a 100644 --- a/frontend/src/components/smart-editor/tabbed-editors/editor.tsx +++ b/frontend/src/components/smart-editor/tabbed-editors/editor.tsx @@ -1,10 +1,11 @@ -import { ClockDashedIcon } from '@navikt/aksel-icons'; -import { skipToken } from '@reduxjs/toolkit/query'; +/* eslint-disable max-lines */ +import { ClockDashedIcon, CloudFillIcon, CloudSlashFillIcon } from '@navikt/aksel-icons'; +import { Tooltip } from '@navikt/ds-react'; import { Plate, isCollapsed, isText } from '@udecode/plate-common'; -import { Profiler, useContext, useEffect, useState } from 'react'; +import { useContext, useEffect, useState } from 'react'; import { BasePoint, Path, Range } from 'slate'; import { styled } from 'styled-components'; -import { SavedStatusProps } from '@app/components/saved-status/saved-status'; +import { StaticDataContext } from '@app/components/app/static-data-context'; import { NewComment } from '@app/components/smart-editor/comments/new-comment'; import { SmartEditorContext } from '@app/components/smart-editor/context'; import { GodeFormuleringer } from '@app/components/smart-editor/gode-formuleringer/gode-formuleringer'; @@ -13,46 +14,38 @@ import { useCanEditDocument } from '@app/components/smart-editor/hooks/use-can-e import { Content } from '@app/components/smart-editor/tabbed-editors/content'; import { PositionedRight } from '@app/components/smart-editor/tabbed-editors/positioned-right'; import { StickyRight } from '@app/components/smart-editor/tabbed-editors/sticky-right'; +import { useRefreshOboToken } from '@app/components/smart-editor/tabbed-editors/use-refresh-obo-token'; +import { VersionStatus } from '@app/components/smart-editor/tabbed-editors/version-status'; import { DocumentErrorComponent } from '@app/error-boundary/document-error'; import { ErrorBoundary } from '@app/error-boundary/error-boundary'; import { useOppgave } from '@app/hooks/oppgavebehandling/use-oppgave'; import { useSmartEditorSpellCheckLanguage } from '@app/hooks/use-smart-editor-language'; -import { editorMeasurements } from '@app/observability'; import { PlateEditor } from '@app/plate/plate-editor'; -import { saksbehandlerPlugins } from '@app/plate/plugins/plugin-sets/saksbehandler'; +import { collaborationSaksbehandlerPlugins } from '@app/plate/plugins/plugin-sets/saksbehandler'; import { Sheet } from '@app/plate/sheet'; import { StatusBar } from '@app/plate/status-bar/status-bar'; import { FloatingSaksbehandlerToolbar } from '@app/plate/toolbar/toolbars/floating-toolbar'; import { SaksbehandlerToolbar } from '@app/plate/toolbar/toolbars/saksbehandler-toolbar'; import { SaksbehandlerTableToolbar } from '@app/plate/toolbar/toolbars/table-toolbar'; -import { EditorValue, RichTextEditor } from '@app/plate/types'; -import { useGetMySignatureQuery, useGetSignatureQuery } from '@app/redux-api/bruker'; +import { EditorValue, RichTextEditor, useMyPlateEditorRef } from '@app/plate/types'; import { useLazyGetDocumentQuery } from '@app/redux-api/oppgaver/queries/documents'; import { ISmartDocument } from '@app/types/documents/documents'; +import { IOppgavebehandling } from '@app/types/oppgavebehandling/oppgavebehandling'; interface EditorProps { smartDocument: ISmartDocument; - onChange: (value: EditorValue) => void; - updateStatus: SavedStatusProps; } -export const Editor = ({ smartDocument, onChange, updateStatus }: EditorProps) => { - const { id, templateId, content } = smartDocument; - const [getDocument, { isLoading }] = useLazyGetDocumentQuery(); - const { newCommentSelection, showAnnotationsAtOrigin } = useContext(SmartEditorContext); +export const Editor = ({ smartDocument }: EditorProps) => { + const { id, templateId } = smartDocument; + const [, { isLoading }] = useLazyGetDocumentQuery(); + const { newCommentSelection } = useContext(SmartEditorContext); + const { user } = useContext(StaticDataContext); const canEdit = useCanEditDocument(templateId); const [showHistory, setShowHistory] = useState(false); const { data: oppgave } = useOppgave(); - const { isLoading: medunderskriverSignatureIsLoading } = useGetSignatureQuery( - typeof oppgave?.medunderskriver.employee?.navIdent === 'string' - ? oppgave.medunderskriver.employee.navIdent - : skipToken, - ); - const { isLoading: saksbehandlerSignatureIsLoading } = useGetMySignatureQuery(); - - // Ensure signatures are initially loaded before rendering the editor in order to avoid unnecessary re-renders and patches - if (oppgave === undefined || medunderskriverSignatureIsLoading || saksbehandlerSignatureIsLoading) { + if (oppgave === undefined) { return null; } @@ -68,11 +61,10 @@ export const Editor = ({ smartDocument, onChange, updateStatus }: EditorProps) = return ( - initialValue={content} + initialValue={smartDocument.content} id={id} readOnly={!canEdit} - onChange={onChange} - plugins={saksbehandlerPlugins} + plugins={collaborationSaksbehandlerPlugins(oppgave.id, id, smartDocument, user)} decorate={([node, path]) => { if (newCommentSelection === null || isCollapsed(newCommentSelection) || !isText(node)) { return []; @@ -99,66 +91,191 @@ export const Editor = ({ smartDocument, onChange, updateStatus }: EditorProps) = return []; }} > - - - - - - - - } - actionButton={{ - onClick: () => getDocument({ dokumentId: id, oppgaveId: oppgave.id }, false).unwrap(), - loading: isLoading, - disabled: isLoading, - buttonText: 'Gjenopprett dokument', - buttonIcon: , - variant: 'primary', - size: 'small', - }} - > - editorMeasurements.add(actualDuration)} - > - - - - - - {showAnnotationsAtOrigin ? : null} - - - {showAnnotationsAtOrigin ? null : } - - {showHistory ? : null} - - - + ); }; -const EditorWithNewCommentAndFloatingToolbar = ({ id }: { id: string }) => { +interface PlateContextProps extends EditorProps { + oppgave: IOppgavebehandling; +} + +const PlateContext = ({ smartDocument, oppgave }: PlateContextProps) => { + const { id, templateId } = smartDocument; + const [getDocument, { isLoading }] = useLazyGetDocumentQuery(); + const { showAnnotationsAtOrigin } = useContext(SmartEditorContext); + const [showHistory, setShowHistory] = useState(false); + const editor = useMyPlateEditorRef(id); + const [isConnected, setIsConnected] = useState(editor.yjs.provider.isConnected); + + const oboTokenIsValid = useRefreshOboToken(); + + useEffect(() => { + // Close happens after connect is broken. Safe to reconnect. + const onClose = () => { + setIsConnected(false); + + if (oboTokenIsValid) { + editor.yjs.provider.connect(); + } + }; + + editor.yjs.provider.on('close', onClose); + + return () => { + editor.yjs.provider.off('close', onClose); + }; + }, [editor.yjs.provider, oboTokenIsValid]); + + useEffect(() => { + // Disconnect happens before close. Too early to reconnect. + const onDisconnect = () => setIsConnected(false); + + // Connect happens after connection is established. + const onConnect = () => setIsConnected(true); + + editor.yjs.provider.on('disconnect', onDisconnect); + editor.yjs.provider.on('connect', onConnect); + + return () => { + editor.yjs.provider.off('disconnect', onDisconnect); + editor.yjs.provider.off('connect', onConnect); + }; + }, [editor.yjs.provider]); + + return ( + <> + + + + + + + + } + actionButton={{ + onClick: () => getDocument({ dokumentId: id, oppgaveId: oppgave.id }, false).unwrap(), + loading: isLoading, + disabled: isLoading, + buttonText: 'Gjenopprett dokument', + buttonIcon: , + variant: 'primary', + size: 'small', + }} + > + + + + + {showAnnotationsAtOrigin ? : null} + + + {showAnnotationsAtOrigin ? null : } + + {showHistory ? : null} + + + + + + {isConnected ? ( + + ) : ( + + )} + + + + + + ); +}; + +const ConnectionIconContainer = styled.span` + margin-left: auto; + margin-right: var(--a-spacing-2); + border-right: 1px solid var(--a-border-default); + padding-left: var(--a-spacing-2); + padding-right: var(--a-spacing-2); + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; +`; + +// interface ChangeSet { +// added: number[]; +// removed: number[]; +// updated: number[]; +// } + +// type OnChangeFn = (changeset: ChangeSet) => void; + +interface EditorWithNewCommentAndFloatingToolbarProps { + id: string; + isConnected: boolean; +} + +const EditorWithNewCommentAndFloatingToolbar = ({ id, isConnected }: EditorWithNewCommentAndFloatingToolbarProps) => { const { templateId, setSheetRef } = useContext(SmartEditorContext); const canEdit = useCanEditDocument(templateId); - const [containerRef, setContainerRef] = useState(null); + const [containerElement, setContainerElement] = useState(null); const lang = useSmartEditorSpellCheckLanguage(); + // const editor = useMyPlateEditorRef(id); + + // useEffect(() => { + // const onChange: OnChangeFn = ({ added, removed, updated }) => { + // const states = editor.awareness.getStates(); + + // requestAnimationFrame(() => { + // cursorStore.store.setState((draft) => { + // for (const a of [...added, ...updated]) { + // const cursor = states.get(a); + + // if (isYjsCursor(cursor) && cursor.data.tabId !== TAB_UUID) { + // draft[a] = { + // ...cursor, + // selection: relativeRangeToSlateRange( + // editor.yjs.provider.document.get('content', XmlText), + // editor, + // cursor.selection, + // ), + // }; + // } + // } + + // for (const r of removed) { + // delete draft[r]; + // } + // }); + // }); + // }; + + // editor.awareness.on('change', onChange); + + // return () => { + // editor.awareness.off('change', onChange); + // }; + // }, [editor, editor.awareness]); + useEffect(() => { - setSheetRef(containerRef); - }, [containerRef, setSheetRef]); + setSheetRef(containerElement); + }, [containerElement, setSheetRef]); return ( - - - + + + + + - + - + {/* Not needed for now - only one person will edit at a time */} + {/* {containerElement === null ? null : } */} ); }; diff --git a/frontend/src/components/smart-editor/tabbed-editors/sticky-right.tsx b/frontend/src/components/smart-editor/tabbed-editors/sticky-right.tsx index 35d57dca0..46c97f59a 100644 --- a/frontend/src/components/smart-editor/tabbed-editors/sticky-right.tsx +++ b/frontend/src/components/smart-editor/tabbed-editors/sticky-right.tsx @@ -1,45 +1,19 @@ -import { useContext, useEffect, useRef } from 'react'; import { styled } from 'styled-components'; -import { Bookmarks, getBookmarks } from '@app/components/smart-editor/bookmarks/bookmarks'; +import { Bookmarks } from '@app/components/smart-editor/bookmarks/bookmarks'; import { CommentSection } from '@app/components/smart-editor/comments/comment-section'; import { NumberOfComments } from '@app/components/smart-editor/comments/number-of-comments'; -import { SmartEditorContext } from '@app/components/smart-editor/context'; -import { useMyPlateEditorState } from '@app/plate/types'; interface StickyRightProps { id: string; } -export const StickyRight = ({ id }: StickyRightProps) => { - const { setInitialBookmarks } = useContext(SmartEditorContext); - const editor = useMyPlateEditorState(id); - const isInitalized = useRef(false); - - useEffect(() => { - if (!isInitalized.current) { - isInitalized.current = true; - setInitialBookmarks(getBookmarks(editor)); - - return; - } - - // Update bookmarks. - const timer = setTimeout( - () => requestIdleCallback(() => setInitialBookmarks(getBookmarks(editor)), { timeout: 200 }), - 500, - ); - - return () => clearTimeout(timer); - }, [editor, editor.children, setInitialBookmarks]); - - return ( - - - - - - ); -}; +export const StickyRight = ({ id }: StickyRightProps) => ( + + + + + +); const StickyRightContainer = styled.div` grid-area: right; diff --git a/frontend/src/components/smart-editor/tabbed-editors/tab-panel.tsx b/frontend/src/components/smart-editor/tabbed-editors/tab-panel.tsx index 98329401e..adfa1911e 100644 --- a/frontend/src/components/smart-editor/tabbed-editors/tab-panel.tsx +++ b/frontend/src/components/smart-editor/tabbed-editors/tab-panel.tsx @@ -1,13 +1,9 @@ import { Tabs } from '@navikt/ds-react'; -import { skipToken } from '@reduxjs/toolkit/query'; -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useRef } from 'react'; import { styled } from 'styled-components'; import { SmartEditorContextComponent } from '@app/components/smart-editor/context'; import { useCanEditDocument } from '@app/components/smart-editor/hooks/use-can-edit-document'; import { Editor } from '@app/components/smart-editor/tabbed-editors/editor'; -import { areDescendantsEqual } from '@app/functions/are-descendants-equal'; -import { useOppgaveId } from '@app/hooks/oppgavebehandling/use-oppgave-id'; -import { useUpdateSmartDocumentMutation } from '@app/redux-api/oppgaver/mutations/smart-document'; import { ISmartDocument } from '@app/types/documents/documents'; interface TabPanelProps { @@ -15,31 +11,12 @@ interface TabPanelProps { } export const TabPanel = ({ smartDocument }: TabPanelProps) => { - const oppgaveId = useOppgaveId(); - const [update, status] = useUpdateSmartDocumentMutation(); - const [localContent, setLocalContent] = useState(smartDocument.content); - const refContent = useRef(smartDocument.content); - - const { id, content } = smartDocument; - + const { id } = smartDocument; const smartDocumentRef = useRef(smartDocument); const canEditDocument = useCanEditDocument(smartDocument.templateId); const canEditDocumentRef = useRef(canEditDocument); - // Normal debounce - useEffect(() => { - const timeout = setTimeout(() => { - if (!canEditDocument || areDescendantsEqual(localContent, content) || oppgaveId === skipToken) { - return; - } - - update({ content: localContent, oppgaveId, dokumentId: id, version: smartDocument.version }); - }, 2_000); - - return () => clearTimeout(timeout); - }, [content, id, oppgaveId, smartDocument.version, update, localContent, canEditDocument]); - // Ensure that smartDocumentRef and canEditDocumentRef are always up to date in order to avoid the unmount debounce triggering on archive/delete/fradeling useEffect(() => { const setRefs = () => { @@ -52,36 +29,10 @@ export const TabPanel = ({ smartDocument }: TabPanelProps) => { return setRefs; }, [canEditDocument, smartDocument]); - // Unmount debounce - useEffect( - () => () => { - if ( - oppgaveId === skipToken || - !canEditDocumentRef.current || - smartDocumentRef.current.isMarkertAvsluttet || - areDescendantsEqual(refContent.current, smartDocumentRef.current.content) - ) { - return; - } - - update({ content: refContent.current, oppgaveId, dokumentId: id, version: smartDocumentRef.current.version }); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [], - ); - return ( - - { - refContent.current = c; - setLocalContent(c); - }} - updateStatus={status} - /> + + ); diff --git a/frontend/src/components/smart-editor/tabbed-editors/use-refresh-obo-token.ts b/frontend/src/components/smart-editor/tabbed-editors/use-refresh-obo-token.ts new file mode 100644 index 000000000..49dc89a7a --- /dev/null +++ b/frontend/src/components/smart-editor/tabbed-editors/use-refresh-obo-token.ts @@ -0,0 +1,98 @@ +import { addSeconds, format } from 'date-fns'; +import { useEffect, useState } from 'react'; +import { ISO_DATETIME_FORMAT } from '@app/components/date-picker/constants'; +import { pushError, pushLog } from '@app/observability'; + +type SetIsValid = (isValid: boolean) => void; + +export const useRefreshOboToken = (): boolean => { + const [isValid, setIsValid] = useState(false); + + useEffect(() => { + const abortController = new AbortController(); + + init(abortController.signal, setIsValid); + + return () => abortController.abort(); + }, []); + + return isValid; +}; + +const RENEW_THRESHOLD = 120; // Number of seconds before the OBO token expires the refresh request is made. + +const init = async (abortSignal: AbortSignal, setIsValid: SetIsValid) => { + const expiresIn = (await getOboTokenExpiresIn(abortSignal)) ?? 0; + + setIsValid(expiresIn > 0); + + refreshLoop(expiresIn, setIsValid, abortSignal); +}; + +const refreshLoop = async (refreshInSeconds: number, setIsValid: SetIsValid, abortSignal: AbortSignal, attempt = 1) => { + if (abortSignal.aborted) { + return; + } + + const seconds = refreshInSeconds - RENEW_THRESHOLD; + const at = format(addSeconds(new Date(), seconds), ISO_DATETIME_FORMAT); + + console.info('Refreshing OBO token in', seconds, 'seconds at', at); + + const timer = setTimeout(async () => { + const expiresIn = await refreshOboToken(abortSignal); + + if (expiresIn === undefined) { + console.warn('Could not refresh OBO token. Trying again in', attempt, 'seconds...'); + + setIsValid(false); + refreshLoop(attempt, setIsValid, abortSignal, attempt + 1); + + return; + } + + setIsValid(expiresIn > 0); + refreshLoop(expiresIn, setIsValid, abortSignal); + }, seconds * 1_000); + + abortSignal.addEventListener('abort', () => { + clearTimeout(timer); + }); +}; + +const getOboTokenExpiresIn = async (abortSignal: AbortSignal) => + getRequest('/collaboration/obo-token-exp', abortSignal); + +const refreshOboToken = async (abortSignal: AbortSignal) => + getRequest('/collaboration/refresh-obo-access-token', abortSignal); + +const getRequest = async (url: string, abortSignal: AbortSignal) => { + try { + const res = await fetch(url, { credentials: 'include', signal: abortSignal }); + + if (!res.ok) { + throw new Error(`API responded with error code ${res.status} for ${url}`); + } + + const data: unknown = await res.json(); + + if (!isExpiresIn(data)) { + throw new Error(`Unexpected response for ${url}`); + } + + return data.expiresIn; + } catch (err) { + if (err instanceof Error) { + pushError(err); + } else { + pushLog(`Request failed. ${url}`); + } + } +}; + +interface ExpiresIn { + expiresIn: number; +} + +const isExpiresIn = (data: unknown): data is ExpiresIn => + typeof data === 'object' && data !== null && 'expiresIn' in data; diff --git a/frontend/src/components/smart-editor/tabbed-editors/version-status.tsx b/frontend/src/components/smart-editor/tabbed-editors/version-status.tsx new file mode 100644 index 000000000..c468fb5e5 --- /dev/null +++ b/frontend/src/components/smart-editor/tabbed-editors/version-status.tsx @@ -0,0 +1,19 @@ +import { isoDateTimeToPretty } from '@app/domain/date'; +import { useGetSmartDocumentVersionsQuery } from '@app/redux-api/oppgaver/queries/documents'; + +interface Props { + oppgaveId: string; + dokumentId: string; +} + +export const VersionStatus = ({ oppgaveId, dokumentId }: Props) => { + const { data = [] } = useGetSmartDocumentVersionsQuery({ dokumentId, oppgaveId }); + + const latest = data.at(0); + + if (latest === undefined) { + return null; + } + + return Sist lagret: {isoDateTimeToPretty(latest.timestamp)}; +}; diff --git a/frontend/src/domain/date.ts b/frontend/src/domain/date.ts index 7851b3cd0..c65a69501 100644 --- a/frontend/src/domain/date.ts +++ b/frontend/src/domain/date.ts @@ -71,19 +71,22 @@ export const isoTimeToPretty = (isoTime: ISOTime | null | undefined): prettyTime const _isoTimeToPretty = (isoTime: ISOTime): prettyTime => isoTime.split('.')[0]!; -export const isoDateToPretty = (isoDate: ISODate | null | undefined): prettyDate | null => { +/** Formats ISO date(time) as human readable. */ +export const isoDateToPretty = (isoDate: ISODate | ISODateTime | null | undefined): prettyDate | null => { if (isoDate === null || isoDate === undefined || isoDate.length === 0) { return null; } - if (!isoDateRegex.test(isoDate)) { - pushLog('Invalid ISO date', { context: { isoDate } }); - console.warn('Invalid ISO date', isoDate); + const [date] = isoDate.split('T'); - return null; + if (date !== undefined && isoDateRegex.test(date)) { + return _isoDateToPretty(date); } - return _isoDateToPretty(isoDate); + pushLog('Invalid ISO date', { context: { isoDate } }); + console.warn('Invalid ISO date', isoDate); + + return null; }; const _isoDateToPretty = (isoDate: ISODate): prettyDate => isoDate.split('-').reverse().join('.'); diff --git a/frontend/src/functions/remove-empty-char-in-text.ts b/frontend/src/functions/remove-empty-char-in-text.ts index aa72605e2..27a38ea60 100644 --- a/frontend/src/functions/remove-empty-char-in-text.ts +++ b/frontend/src/functions/remove-empty-char-in-text.ts @@ -1,4 +1,4 @@ -const EMPTY_CHAR_CODE = 8203; +export const EMPTY_CHAR_CODE = 8203; const REMOVE_REGEX = new RegExp(String.fromCharCode(EMPTY_CHAR_CODE), 'g'); export const removeEmptyCharInText = (text: string): string => text.replace(REMOVE_REGEX, ''); diff --git a/frontend/src/headers.ts b/frontend/src/headers.ts index 80ae91ec3..7a0118312 100644 --- a/frontend/src/headers.ts +++ b/frontend/src/headers.ts @@ -1,7 +1,7 @@ import { ENVIRONMENT } from '@app/environment'; import { generateTraceParent } from '@app/functions/generate-request-id'; -const tabId = crypto.randomUUID(); +export const TAB_UUID = crypto.randomUUID(); enum HeaderKeys { TRACEPARENT = 'traceparent', @@ -18,13 +18,13 @@ enum QueryKeys { export const getHeaders = () => ({ [HeaderKeys.TRACEPARENT]: generateTraceParent(), [HeaderKeys.VERSION]: ENVIRONMENT.version, - [HeaderKeys.TAB_ID]: tabId, + [HeaderKeys.TAB_ID]: TAB_UUID, }); export const setHeaders = (headers: Headers): Headers => { headers.set(HeaderKeys.TRACEPARENT, generateTraceParent()); headers.set(HeaderKeys.VERSION, ENVIRONMENT.version); - headers.set(HeaderKeys.TAB_ID, tabId); + headers.set(HeaderKeys.TAB_ID, TAB_UUID); return headers; }; @@ -33,5 +33,5 @@ export const getQueryParams = () => { const { version } = ENVIRONMENT; const traceParent = generateTraceParent(); - return `${QueryKeys.VERSION}=${version}&${QueryKeys.TAB_ID}=${tabId}&${QueryKeys.TRACEPARENT}=${traceParent}`; + return `${QueryKeys.VERSION}=${version}&${QueryKeys.TAB_ID}=${TAB_UUID}&${QueryKeys.TRACEPARENT}=${traceParent}`; }; diff --git a/frontend/src/hooks/use-can-edit.ts b/frontend/src/hooks/use-can-edit.ts index e8f99bc8d..3cceaaab6 100644 --- a/frontend/src/hooks/use-can-edit.ts +++ b/frontend/src/hooks/use-can-edit.ts @@ -1,5 +1,6 @@ import { useContext, useMemo } from 'react'; import { StaticDataContext } from '@app/components/app/static-data-context'; +import { FlowState } from '@app/types/oppgave-common'; import { useOppgave } from './oppgavebehandling/use-oppgave'; export const useCanEdit = () => { @@ -7,15 +8,34 @@ export const useCanEdit = () => { const { user } = useContext(StaticDataContext); return useMemo(() => { - if (oppgavebehandlingIsLoading || typeof oppgavebehandling === 'undefined') { + if ( + oppgavebehandlingIsLoading || + oppgavebehandling === undefined || + oppgavebehandling.isAvsluttetAvSaksbehandler || + oppgavebehandling.feilregistrering !== null || + oppgavebehandling.saksbehandler === null + ) { return false; } - return ( - !oppgavebehandling.isAvsluttetAvSaksbehandler && - oppgavebehandling.saksbehandler !== null && - oppgavebehandling.saksbehandler.navIdent === user.navIdent && - oppgavebehandling.feilregistrering === null - ); + return oppgavebehandling.saksbehandler.navIdent === user.navIdent; }, [oppgavebehandling, oppgavebehandlingIsLoading, user.navIdent]); }; + +export const useCanEditBehandling = () => { + const { data: oppgavebehandling, isLoading: oppgavebehandlingIsLoading } = useOppgave(); + + const canEdit = useCanEdit(); + + return useMemo(() => { + if ( + oppgavebehandlingIsLoading || + oppgavebehandling === undefined || + oppgavebehandling.medunderskriver.flowState === FlowState.SENT + ) { + return false; + } + + return canEdit; + }, [canEdit, oppgavebehandling, oppgavebehandlingIsLoading]); +}; diff --git a/frontend/src/hooks/use-is-rol.ts b/frontend/src/hooks/use-is-rol.ts index 98d085ef2..c52c6c78e 100644 --- a/frontend/src/hooks/use-is-rol.ts +++ b/frontend/src/hooks/use-is-rol.ts @@ -5,20 +5,36 @@ import { FlowState } from '@app/types/oppgave-common'; import { useOppgave } from './oppgavebehandling/use-oppgave'; export const useIsRol = () => { - const { data: oppgave, isLoading } = useOppgave(); + const { data: oppgave, isSuccess } = useOppgave(); + const isRolWithAnyFlowState = useIsRolWithAnyFlowState(); + + return useMemo(() => { + if (!isSuccess) { + return false; + } + + return ( + isRolWithAnyFlowState && + (oppgave.typeId === SaksTypeEnum.KLAGE || oppgave.typeId === SaksTypeEnum.ANKE) && + oppgave.rol.flowState !== FlowState.NOT_SENT + ); + }, [oppgave, isSuccess, isRolWithAnyFlowState]); +}; + +export const useIsRolWithAnyFlowState = () => { + const { data: oppgave, isSuccess } = useOppgave(); const { user } = useContext(StaticDataContext); return useMemo(() => { - if (isLoading || oppgave === undefined) { + if (!isSuccess) { return false; } return ( (oppgave.typeId === SaksTypeEnum.KLAGE || oppgave.typeId === SaksTypeEnum.ANKE) && oppgave.rol.employee !== null && - oppgave.rol.flowState !== FlowState.NOT_SENT && oppgave.rol.employee.navIdent === user.navIdent ); - }, [oppgave, isLoading, user]); + }, [oppgave, isSuccess, user]); }; diff --git a/frontend/src/hooks/use-navigate-maltekstseksjoner.ts b/frontend/src/hooks/use-navigate-maltekstseksjoner.ts index 2aeb1f4f3..de903b27e 100644 --- a/frontend/src/hooks/use-navigate-maltekstseksjoner.ts +++ b/frontend/src/hooks/use-navigate-maltekstseksjoner.ts @@ -1,5 +1,6 @@ import { useCallback } from 'react'; import { useLocation, useNavigate, useParams } from 'react-router'; +import { useSearchParams } from 'react-router-dom'; import { Language } from '@app/types/texts/language'; interface PathParams { @@ -7,17 +8,27 @@ interface PathParams { maltekstseksjonVersionId?: string | null; textId?: string | null; lang?: Language | null; + trash?: boolean; } export const useNavigateMaltekstseksjoner = () => { const oldParams = useParams(); const navigate = useNavigate(); - const { search } = useLocation(); + const [searchParams] = useSearchParams(); return useCallback( - (newParams: PathParams, replace: boolean = false) => - navigate(`${calculateMaltekstseksjonPath(oldParams, newParams)}${search}`, { replace }), - [navigate, oldParams, search], + (newParams: PathParams, replace: boolean = false) => { + const path = calculateMaltekstseksjonPath(oldParams, newParams); + + if (newParams.trash === true) { + searchParams.set('trash', 'true'); + } else if (newParams.trash === false) { + searchParams.delete('trash'); + } + + return navigate(`${path}${searchParams.toString()}`, { replace }); + }, + [navigate, oldParams, searchParams], ); }; diff --git a/frontend/src/hooks/use-navigate-to-standalone-text-version.ts b/frontend/src/hooks/use-navigate-to-standalone-text-version.ts index b0f199c5d..15ea7e593 100644 --- a/frontend/src/hooks/use-navigate-to-standalone-text-version.ts +++ b/frontend/src/hooks/use-navigate-to-standalone-text-version.ts @@ -1,25 +1,39 @@ import { useCallback, useEffect } from 'react'; import { useLocation, useNavigate, useParams } from 'react-router'; +import { useSearchParams } from 'react-router-dom'; import { Language } from '@app/types/texts/language'; interface Params { id?: string | null; versionId?: string | null; lang?: string; + trash?: boolean; } export const useNavigateToStandaloneTextVersion = (hasLanguage: boolean) => { const navigate = useNavigate(); - const { pathname, search } = useLocation(); + const { pathname } = useLocation(); const oldParams = useParams(); const { lang } = useParams(); + const [searchParams] = useSearchParams(); const [, rootPath] = pathname.split('/'); const navigateToText = useCallback( - (newParams: Params, replace = false) => - navigate(`${calculatePath(rootPath, oldParams, newParams, hasLanguage)}${search}`, { replace }), - [hasLanguage, navigate, oldParams, rootPath, search], + (newParams: Params, replace = false) => { + const path = calculatePath(rootPath, oldParams, newParams, hasLanguage); + + if (newParams.trash === true) { + searchParams.set('trash', 'true'); + } else if (newParams.trash === false) { + searchParams.delete('trash'); + } + + return navigate(`${path}${searchParams.toString()}`, { + replace, + }); + }, + [hasLanguage, navigate, oldParams, rootPath, searchParams], ); useEffect(() => { diff --git a/frontend/src/hooks/use-smart-documents.ts b/frontend/src/hooks/use-smart-documents.ts index 0ffc6737f..fb47317f9 100644 --- a/frontend/src/hooks/use-smart-documents.ts +++ b/frontend/src/hooks/use-smart-documents.ts @@ -1,6 +1,6 @@ import { skipToken } from '@reduxjs/toolkit/query'; import { useHasRole } from '@app/hooks/use-has-role'; -import { useIsRol } from '@app/hooks/use-is-rol'; +import { useIsRolWithAnyFlowState } from '@app/hooks/use-is-rol'; import { useGetDocumentsQuery } from '@app/redux-api/oppgaver/queries/documents'; import { Role } from '@app/types/bruker'; import { ISmartDocument } from '@app/types/documents/documents'; @@ -8,7 +8,7 @@ import { TemplateIdEnum } from '@app/types/smart-editor/template-enums'; export const useSmartDocuments = (oppgaveId: string | typeof skipToken): ISmartDocument[] | undefined => { const { data, isLoading } = useGetDocumentsQuery(oppgaveId); - const isRol = useIsRol(); + const isRolWithAnyFlowState = useIsRolWithAnyFlowState(); const hasSaksbehandlerRole = useHasRole(Role.KABAL_SAKSBEHANDLING); if (isLoading || typeof data === 'undefined') { @@ -25,7 +25,7 @@ export const useSmartDocuments = (oppgaveId: string | typeof skipToken): ISmartD } } - if (isRol) { + if (isRolWithAnyFlowState) { for (const document of data) { if (document.isSmartDokument && document.templateId === TemplateIdEnum.ROL_ANSWERS) { documents.push(document); diff --git a/frontend/src/observability.tsx b/frontend/src/observability.tsx index daf5acd96..d59153a34 100644 --- a/frontend/src/observability.tsx +++ b/frontend/src/observability.tsx @@ -3,10 +3,6 @@ import { LogLevel, PushLogOptions, faro } from '@grafana/faro-web-sdk'; import { TracingInstrumentation } from '@grafana/faro-web-tracing'; import { Routes, createRoutesFromChildren, matchRoutes, useLocation, useNavigationType } from 'react-router-dom'; import { ENVIRONMENT } from '@app/environment'; -import { user } from '@app/static-data/static-data'; -import { IUserData } from '@app/types/bruker'; - -const MEASURE_TIME_INTERVAL = 10_000; const getUrl = () => { if (ENVIRONMENT.isProduction) { @@ -54,46 +50,3 @@ export const pushLog = (message: string, options?: Omit { - this.user = userData; - }); - - setInterval(() => { - if (this.measurements.length === 0) { - return; - } - - const max = Math.max(...this.measurements); - const average = this.measurements.reduce((a, b) => a + b, 0) / this.measurements.length; - - faro.api.setUser({ id: this.user?.navIdent ?? 'Unknown user' }); - - pushMeasurement({ - type: 'render_smart_editor', - values: { render_smart_editor_max: max, render_smart_editor_avg: average }, - }); - - faro.api.resetUser(); - - this.measurements = []; - }, MEASURE_TIME_INTERVAL); - } - - add = (render_smart_editor: number) => { - // Drop 10 first measurements to avoid logging cold start - if (++this.counter < 10) { - return; - } - - this.measurements.push(render_smart_editor); - }; -} - -export const editorMeasurements = new EditorMeasurements(); diff --git a/frontend/src/pages/behandling-etter-tr-opphevet/behandling-etter-tr-opphevet.tsx b/frontend/src/pages/behandling-etter-tr-opphevet/behandling-etter-tr-opphevet.tsx new file mode 100644 index 000000000..7688c4c34 --- /dev/null +++ b/frontend/src/pages/behandling-etter-tr-opphevet/behandling-etter-tr-opphevet.tsx @@ -0,0 +1,3 @@ +import { Oppgavebehandling } from '@app/components/oppgavebehandling/oppgavebehandling'; + +export const BehandlingEtterTrOpphevetPage = () => ; diff --git a/frontend/src/pages/search/search.tsx b/frontend/src/pages/search/search.tsx index 2eeee69cb..13e6b7302 100644 --- a/frontend/src/pages/search/search.tsx +++ b/frontend/src/pages/search/search.tsx @@ -1,17 +1,8 @@ -import { useState } from 'react'; -import { FnrSearch } from '@app/components/search/fnr/fnr-search'; -import { NameSearch } from '@app/components/search/name/name-search'; -import { SearchBox } from '@app/components/searchbox/searchbox'; +import { OppgaveSearch } from '@app/components/search/oppgave-search'; import { SearchPageWrapper } from '../page-wrapper'; -export const SearchPage = () => { - const [query, setQuery] = useState(''); - - return ( - - - - - - ); -}; +export const SearchPage = () => ( + + + +); diff --git a/frontend/src/plate/components/header-footer.tsx b/frontend/src/plate/components/header-footer.tsx index caa275dcf..cc838d69c 100644 --- a/frontend/src/plate/components/header-footer.tsx +++ b/frontend/src/plate/components/header-footer.tsx @@ -5,7 +5,8 @@ import { useCallback, useContext, useEffect, useState } from 'react'; import { styled } from 'styled-components'; import { StaticDataContext } from '@app/components/app/static-data-context'; import { SmartEditorContext } from '@app/components/smart-editor/context'; -import { useQuery } from '@app/components/smart-editor/hooks/use-query'; +import { useCanManageDocument } from '@app/components/smart-editor/hooks/use-can-edit-document'; +import { useHeaderFooterQuery } from '@app/components/smart-editor/hooks/use-query'; import { AddNewParagraphAbove, AddNewParagraphBelow } from '@app/plate/components/common/add-new-paragraph-buttons'; import { SectionContainer, SectionToolbar, SectionTypeEnum } from '@app/plate/components/styled-components'; import { ELEMENT_FOOTER, ELEMENT_HEADER } from '@app/plate/plugins/element-types'; @@ -51,7 +52,9 @@ const RenderHeaderFooter = ({ element, attributes, children }: PlateRenderElemen const [getTexts, { isLoading, isUninitialized }] = useLazyGetConsumerTextsQuery(); const editor = useMyPlateEditorRef(); - const query = useQuery({ textType }); + const query = useHeaderFooterQuery(textType); + const { templateId } = useContext(SmartEditorContext); + const canManage = useCanManageDocument(templateId); const loadMaltekst = useCallback( async (e: ElementTypes) => { @@ -109,9 +112,11 @@ const RenderHeaderFooter = ({ element, attributes, children }: PlateRenderElemen {children} - - - + {canManage ? ( + + + + ) : null} ); diff --git a/frontend/src/plate/components/legacy-redigerbar-maltekst.tsx b/frontend/src/plate/components/legacy-redigerbar-maltekst.tsx index 45b1c389f..d4ea6a758 100644 --- a/frontend/src/plate/components/legacy-redigerbar-maltekst.tsx +++ b/frontend/src/plate/components/legacy-redigerbar-maltekst.tsx @@ -4,6 +4,7 @@ import { skipToken } from '@reduxjs/toolkit/query'; import { PlateElement, PlateRenderElementProps, findNodePath, replaceNodeChildren } from '@udecode/plate-common'; import { useCallback, useContext, useEffect, useRef } from 'react'; import { SmartEditorContext } from '@app/components/smart-editor/context'; +import { useCanManageDocument } from '@app/components/smart-editor/hooks/use-can-edit-document'; import { useQuery } from '@app/components/smart-editor/hooks/use-query'; import { useOppgave } from '@app/hooks/oppgavebehandling/use-oppgave'; import { AddNewParagraphs } from '@app/plate/components/common/add-new-paragraph-buttons'; @@ -27,6 +28,9 @@ const consistsOfOnlyEmptyVoid = (element: RedigerbarMaltekstElement) => { return isOfElementType(child, ELEMENT_EMPTY_VOID); }; +/** + * @deprecated Remove when all smart documents in prod use maltekstseksjon. + */ export const LegacyRedigerbarMaltekst = ({ attributes, children, @@ -43,6 +47,7 @@ export const LegacyRedigerbarMaltekst = ({ const path = findNodePath(editor, element); const isInitialized = useRef(!isNodeEmpty(element)); + const canManage = useCanManageDocument(templateId); const insertRedigerbarMaltekst = useCallback(async () => { if (query === skipToken || path === undefined || oppgaveIsLoading || oppgave === undefined) { @@ -109,19 +114,21 @@ export const LegacyRedigerbarMaltekst = ({ $sectionType={SectionTypeEnum.REDIGERBAR_MALTEKST} > {children} - - - - diff --git a/frontend/src/plate/components/maltekstseksjon/use-update.ts b/frontend/src/plate/components/maltekstseksjon/use-update.ts index d67ffbe8b..99ca30ccf 100644 --- a/frontend/src/plate/components/maltekstseksjon/use-update.ts +++ b/frontend/src/plate/components/maltekstseksjon/use-update.ts @@ -1,60 +1,68 @@ -import { PlateEditor } from '@udecode/plate-common'; -import { useCallback } from 'react'; -import { Path } from 'slate'; +import { SkipToken, skipToken } from '@reduxjs/toolkit/query'; +import { useCallback, useEffect, useState } from 'react'; import { useOppgave } from '@app/hooks/oppgavebehandling/use-oppgave'; import { useSmartEditorLanguage } from '@app/hooks/use-smart-editor-language'; import { getNewChildren } from '@app/plate/components/maltekstseksjon/get-children'; import { replaceNodes } from '@app/plate/components/maltekstseksjon/replace-nodes'; import { MaltekstseksjonUpdate } from '@app/plate/components/maltekstseksjon/types'; import { ReplaceMethod, useGetReplaceMethod } from '@app/plate/components/maltekstseksjon/use-get-replace-method'; +import { usePath } from '@app/plate/components/maltekstseksjon/use-path'; import { LexSpecialisStatus, ScoredText, lexSpecialis } from '@app/plate/functions/lex-specialis/lex-specialis'; -import { EditorValue, MaltekstseksjonElement } from '@app/plate/types'; +import { MaltekstseksjonElement, useMyPlateEditorRef } from '@app/plate/types'; import { useLazyGetConsumerMaltekstseksjonerQuery, useLazyGetMaltekstseksjonTextsQuery, } from '@app/redux-api/maltekstseksjoner/consumer'; -import { IGetTextsParams } from '@app/types/common-text-types'; +import { IGetConsumerMaltekstseksjonerParams } from '@app/types/common-text-types'; import { IMaltekstseksjon } from '@app/types/maltekstseksjoner/responses'; import { IOppgavebehandling } from '@app/types/oppgavebehandling/oppgavebehandling'; import { TemplateIdEnum } from '@app/types/smart-editor/template-enums'; -type UpdateMaltekstseksjonFn = ( - element: MaltekstseksjonElement, - resultat: IOppgavebehandling['resultat'], - ytelseId: IOppgavebehandling['ytelseId'], - query: Omit, -) => Promise; - interface Result { - updateMaltekstseksjon: UpdateMaltekstseksjonFn; isFetching: boolean; + tiedList: ScoredList; + maltekstseksjon: IMaltekstseksjon | null; + manualUpdate: MaltekstseksjonUpdate | null | undefined; + update: (preferCache: boolean) => void; } type ScoredList = ScoredText[]; -export const NO_TIED_LIST: ScoredList = []; +const NO_TIED_LIST: ScoredList = []; export const useUpdateMaltekstseksjon = ( - editor: PlateEditor, - path: Path | undefined, + editorId: string, + element: MaltekstseksjonElement, + query: IGetConsumerMaltekstseksjonerParams | SkipToken, templateId: TemplateIdEnum, - setIsUpdating: (isUpdating: boolean) => void, - setManualUpdate: (manualUpdate: MaltekstseksjonUpdate | null | undefined) => void, - setTiedList: (tiedList: ScoredList) => void, - setMaltekstseksjon: (maltekstseksjon: IMaltekstseksjon | null) => void, + ytelseId: IOppgavebehandling['ytelseId'], + resultat: IOppgavebehandling['resultat'], + onUpdate: () => void, + isUpdated: boolean, + canManage: boolean, ): Result => { + const editor = useMyPlateEditorRef(editorId); const language = useSmartEditorLanguage(); + const path = usePath(editor, element); + const [tiedList, setTiedList] = useState(NO_TIED_LIST); + const [manualUpdate, setManualUpdate] = useState(undefined); + const [maltekstseksjon, setMaltekstseksjon] = useState(null); const { data: oppgave } = useOppgave(); const oppgaveIsLoaded = oppgave !== undefined; const getReplaceMethod = useGetReplaceMethod(oppgaveIsLoaded); - const [fetchMaltekstseksjon, { isFetching: maltekstseksjonIsFetching }] = useLazyGetConsumerMaltekstseksjonerQuery(); + const [fetchMaltekstseksjoner, { isFetching: maltekstseksjonIsFetching }] = + useLazyGetConsumerMaltekstseksjonerQuery(); const [fetchMaltekstseksjonTexts, { isFetching: textsAreFetching }] = useLazyGetMaltekstseksjonTextsQuery(); - const updateMaltekstseksjon = useCallback( - async (element, resultat, ytelseId, query) => { - const { utfallId, extraUtfallIdSet, hjemmelIdSet } = resultat; + const update = useCallback( + async (preferCache = true) => { + if (isUpdated || !canManage || query === skipToken) { + return; + } - const maltekstseksjoner = await fetchMaltekstseksjon({ ...query, trash: false }).unwrap(); + const maltekstseksjoner = await fetchMaltekstseksjoner(query, preferCache).unwrap(); + + const { utfallId, extraUtfallIdSet, hjemmelIdSet } = resultat; const [lexSpecialisStatus, result] = lexSpecialis( templateId, @@ -66,6 +74,7 @@ export const useUpdateMaltekstseksjon = ( ); const isTie = lexSpecialisStatus === LexSpecialisStatus.TIE; + setTiedList(isTie ? result : NO_TIED_LIST); if (isTie || lexSpecialisStatus === LexSpecialisStatus.NONE) { @@ -74,48 +83,64 @@ export const useUpdateMaltekstseksjon = ( if (replaceMethod === ReplaceMethod.AUTO) { replaceNodes(editor, path, null, null, null); + onUpdate(); setManualUpdate(undefined); } else if (replaceMethod === ReplaceMethod.MANUAL) { setManualUpdate(null); } else { + onUpdate(); setManualUpdate(undefined); } } else { setMaltekstseksjon(result); const { id, textIdList } = result; - const texts = await fetchMaltekstseksjonTexts({ id, language }).unwrap(); + const texts = await fetchMaltekstseksjonTexts({ id, language }, preferCache).unwrap(); const newChildren = getNewChildren(texts, element, element.section, language); const replaceMethod = await getReplaceMethod(element, result, newChildren); if (replaceMethod === ReplaceMethod.AUTO) { replaceNodes(editor, path, id, textIdList, newChildren); + onUpdate(); setManualUpdate(undefined); } else if (replaceMethod === ReplaceMethod.MANUAL) { setManualUpdate({ maltekstseksjon: result, children: newChildren }); } else { + onUpdate(); setManualUpdate(undefined); } } - - requestIdleCallback(() => setIsUpdating(false)); }, [ - fetchMaltekstseksjon, - templateId, - setTiedList, - setMaltekstseksjon, - getReplaceMethod, + canManage, editor, - path, - setManualUpdate, + element, fetchMaltekstseksjonTexts, + fetchMaltekstseksjoner, + getReplaceMethod, + isUpdated, language, - setIsUpdating, + onUpdate, + path, + query, + resultat, + templateId, + ytelseId, ], ); + useEffect(() => { + if (isUpdated || maltekstseksjonIsFetching || textsAreFetching || !canManage || query === skipToken) { + return; + } + + update(); + }, [canManage, element.section, isUpdated, maltekstseksjonIsFetching, query, textsAreFetching, update]); + return { - updateMaltekstseksjon, isFetching: maltekstseksjonIsFetching || textsAreFetching, + tiedList, + maltekstseksjon, + manualUpdate, + update, }; }; diff --git a/frontend/src/plate/components/placeholder/helpers.ts b/frontend/src/plate/components/placeholder/helpers.ts index 500bab428..3d8de7a26 100644 --- a/frontend/src/plate/components/placeholder/helpers.ts +++ b/frontend/src/plate/components/placeholder/helpers.ts @@ -11,7 +11,7 @@ import { withoutSavingHistory, } from '@udecode/plate-common'; import { Path } from 'slate'; -import { removeEmptyCharInText } from '@app/functions/remove-empty-char-in-text'; +import { EMPTY_CHAR_CODE, removeEmptyCharInText } from '@app/functions/remove-empty-char-in-text'; import { ELEMENT_MALTEKST } from '@app/plate/plugins/element-types'; import { EditorDescendant, @@ -23,11 +23,10 @@ import { } from '@app/plate/types'; import { isNodeEmpty, isOfElementType } from '@app/plate/utils/queries'; -const EMPTY_CHAR_CODE = 8203; const EMPTY_CHAR = String.fromCharCode(EMPTY_CHAR_CODE); // \u200b export const cleanText = (editor: RichTextEditor, element: PlaceholderElement, path: TPath, at: TPath) => { - const _cleanText: RichText[] = element.children.map((c) => ({ ...c, text: removeEmptyCharInText(c.text) })); + const cleanedText: RichText[] = element.children.map((c) => ({ ...c, text: removeEmptyCharInText(c.text) })); withoutSavingHistory(editor, () => { withoutNormalizing(editor, () => { @@ -35,7 +34,7 @@ export const cleanText = (editor: RichTextEditor, element: PlaceholderElement, p at: path, match: (n) => n !== element, }); - insertNodes(editor, _cleanText, { at, select: true }); + insertNodes(editor, cleanedText, { at, select: true }); }); }); }; @@ -58,7 +57,7 @@ export const insertEmptyChar = (editor: RichTextEditor, at: TPath) => { }; export const getHasNoVisibleText = (text: string): boolean => { - if (hasZeroChars(text)) { + if (getHasZeroChars(text)) { return true; } @@ -71,9 +70,9 @@ export const getHasNoVisibleText = (text: string): boolean => { return true; }; -export const hasZeroChars = (text: string): boolean => text.length === 0; +export const getHasZeroChars = (text: string): boolean => text.length === 0; -export const containsEmptyChar = (text: string): boolean => { +export const getContainsEmptyChar = (text: string): boolean => { for (const char of text) { if (char.charCodeAt(0) === EMPTY_CHAR_CODE) { return true; @@ -84,7 +83,7 @@ export const containsEmptyChar = (text: string): boolean => { }; export const containsMultipleEmptyCharAndNoText = (text: string): boolean => { - if (hasZeroChars(text)) { + if (getHasZeroChars(text)) { return false; } diff --git a/frontend/src/plate/components/placeholder/placeholder.tsx b/frontend/src/plate/components/placeholder/placeholder.tsx index c1bbb3ea2..88b54b459 100644 --- a/frontend/src/plate/components/placeholder/placeholder.tsx +++ b/frontend/src/plate/components/placeholder/placeholder.tsx @@ -7,15 +7,18 @@ import { focusEditor, useEditorReadOnly, } from '@udecode/plate-common'; -import { MouseEvent, useCallback, useEffect, useMemo } from 'react'; +import { MouseEvent, useCallback, useContext, useEffect, useMemo } from 'react'; +import { SmartEditorContext } from '@app/components/smart-editor/context'; +import { useCanManageDocument } from '@app/components/smart-editor/hooks/use-can-edit-document'; +import { removeEmptyCharInText } from '@app/functions/remove-empty-char-in-text'; import { cleanText, - containsEmptyChar, - containsMultipleEmptyCharAndNoText, + containsMultipleEmptyCharAndNoText as containsMultipleEmptyChars, ensureOnlyOneEmptyChar, + getContainsEmptyChar, getHasNoVisibleText, + getHasZeroChars, getIsFocused, - hasZeroChars, insertEmptyChar, lonePlaceholderInMaltekst, } from '@app/plate/components/placeholder/helpers'; @@ -33,6 +36,9 @@ export const Placeholder = ({ const hasNoVisibleText = useMemo(() => getHasNoVisibleText(text), [text]); const isReadOnly = useEditorReadOnly(); const isDragging = window.getSelection()?.isCollapsed === false; + const containsEmptyChar = getContainsEmptyChar(text); + const { templateId } = useContext(SmartEditorContext); + const canManage = useCanManageDocument(templateId); const onClick = useCallback( (e: React.MouseEvent) => { @@ -46,19 +52,15 @@ export const Placeholder = ({ e.preventDefault(); - editor.select({ path: [...path, 0], offset: 0 }); + editor.select({ path: [...path, 0], offset: containsEmptyChar ? 1 : 0 }); }, - [editor, hasNoVisibleText, path], + [containsEmptyChar, editor, hasNoVisibleText, path], ); const isFocused = path === undefined ? false : getIsFocused(editor, path); useEffect(() => { - if (isDragging) { - return; - } - - if (path === undefined) { + if (isDragging || path === undefined) { return; } @@ -68,29 +70,32 @@ export const Placeholder = ({ return; } - // Multiple empty chars. -> One empty char. - // Focus + Contains only text. -> Nothing to do. - // Focus + Contains only empty chars. -> One empty char. - // Focus + Contains empty chars and text. -> Only text. - // Focus + Completely empty placeholder. -> One empty char. - // No focus + Completely empty placeholder. -> One empty char. + // Contains only text. -> Nothing to do. + // Contains only empty chars. -> One empty char. + // Contains empty chars and text. -> Only text. + // Completely empty placeholder. -> One empty char. + if (text.length > 0 && !getContainsEmptyChar(text)) { + return; + } - // Undo (Ctrl + Z) causes the placeholder to contain two empty chars. This cleans that up. - if (containsMultipleEmptyCharAndNoText(text)) { - ensureOnlyOneEmptyChar(editor, element, path, at); + if (getHasZeroChars(text)) { + return insertEmptyChar(editor, at); } - if (isFocused) { - // Only text. - if (!containsEmptyChar(text)) { - return; - } + // Workaround for race condition causing double insert on first character in empty placeholder + if (!isFocused) { + return; + } - return cleanText(editor, element, path, at); + const cleanedText = removeEmptyCharInText(text); + + // Undo (Ctrl + Z) causes the placeholder to contain two empty chars. This cleans that up. + if (containsMultipleEmptyChars(text) && cleanedText.length === 0) { + return ensureOnlyOneEmptyChar(editor, element, path, at); } - if (hasZeroChars(text)) { - return insertEmptyChar(editor, at); + if (cleanedText.length > 0 && getContainsEmptyChar(text)) { + return cleanText(editor, element, path, at); } }, [editor, element, isDragging, isFocused, path, text]); @@ -109,8 +114,8 @@ export const Placeholder = ({ ); const hideDeleteButton = useMemo( - () => !hasNoVisibleText || lonePlaceholderInMaltekst(editor, element, path), - [editor, element, hasNoVisibleText, path], + () => !canManage || !hasNoVisibleText || lonePlaceholderInMaltekst(editor, element, path), + [editor, element, hasNoVisibleText, canManage, path], ); return ( @@ -124,20 +129,16 @@ export const Placeholder = ({ > {children} - {hideDeleteButton ? null : ( - + {hideDeleteButton || isReadOnly ? null : ( + )} diff --git a/frontend/src/plate/components/placeholder/styled-components.ts b/frontend/src/plate/components/placeholder/styled-components.ts index e3b3b7b55..703476115 100644 --- a/frontend/src/plate/components/placeholder/styled-components.ts +++ b/frontend/src/plate/components/placeholder/styled-components.ts @@ -1,29 +1,5 @@ import { styled } from 'styled-components'; -interface WrapperStyleProps { - $placeholder: string; - $focused: boolean; - $hasText: boolean; - $hasButton: boolean; -} - -export const Wrapper = styled.span` - display: inline-block; - background-color: ${({ $focused }) => getBackgroundColor($focused)}; - border-radius: var(--a-border-radius-medium); - outline: none; - color: #000; - padding-left: ${({ $hasButton }) => ($hasButton ? '1em' : '0')}; - position: relative; - - &::after { - cursor: text; - color: var(--a-text-subtle); - content: ${({ $hasText, $placeholder }) => ($hasText ? '""' : `"${$placeholder}"`)}; - user-select: none; - } -`; - export const DeleteButton = styled.button` background: none; border: none; @@ -41,10 +17,6 @@ export const DeleteButton = styled.button` top: 0; &:hover { - &:disabled { - background: none; - } - background-color: var(--a-surface-neutral-subtle-hover); } @@ -57,11 +29,19 @@ export const DeleteButton = styled.button` inset 0 0 0 2px var(--a-border-strong), var(--a-shadow-focus); } +`; + +export const Wrapper = styled.span` + display: inline-block; + border-radius: var(--a-border-radius-medium); + outline: none; + color: var(--a-text-default); + position: relative; - &:disabled { - cursor: not-allowed; - color: #444; + &::after { + cursor: text; + color: var(--a-text-subtle); + content: attr(data-placeholder); + user-select: none; } `; - -const getBackgroundColor = (focused: boolean) => (focused ? 'var(--a-blue-100)' : 'var(--a-gray-200)'); diff --git a/frontend/src/plate/components/redigerbar-maltekst.tsx b/frontend/src/plate/components/redigerbar-maltekst.tsx index f48e3253a..c2bb8ccd5 100644 --- a/frontend/src/plate/components/redigerbar-maltekst.tsx +++ b/frontend/src/plate/components/redigerbar-maltekst.tsx @@ -7,6 +7,9 @@ import { isEditorReadOnly, replaceNodeChildren, } from '@udecode/plate-common'; +import { useContext } from 'react'; +import { SmartEditorContext } from '@app/components/smart-editor/context'; +import { useCanManageDocument } from '@app/components/smart-editor/hooks/use-can-edit-document'; import { useSmartEditorLanguage } from '@app/hooks/use-smart-editor-language'; import { LegacyRedigerbarMaltekst } from '@app/plate/components/legacy-redigerbar-maltekst'; import { SectionContainer, SectionToolbar, SectionTypeEnum } from '@app/plate/components/styled-components'; @@ -23,6 +26,8 @@ export const RedigerbarMaltekst = ({ }: PlateRenderElementProps) => { const [getText, { isFetching }] = useLazyGetConsumerTextByIdQuery(); const language = useSmartEditorLanguage(); + const { templateId } = useContext(SmartEditorContext); + const canManage = useCanManageDocument(templateId); const reload = async () => { if (element.id === undefined) { @@ -63,7 +68,7 @@ export const RedigerbarMaltekst = ({ $sectionType={SectionTypeEnum.REDIGERBAR_MALTEKST} > {children} - {readOnly ? null : ( + {readOnly || !canManage ? null : (