Compare commits

..
Author SHA1 Message Date
github-actions[bot]andgithub-actions[bot] 8f317878fc Version Packages (#2875)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-04-22 15:48:59 +02:00
KenandHalvor Haugan c3064a01f7 🎨 Spacer er nå span (tidligere div) (#2874)
* 🎨 Spacer er nå span (tidligere div)

* 📝 Changeset

* Update .changeset/thick-humans-smile.md

Co-authored-by: Halvor Haugan <83693529+HalvorHaugan@users.noreply.github.com>

---------

Co-authored-by: Halvor Haugan <83693529+HalvorHaugan@users.noreply.github.com>
2024-04-22 15:32:57 +02:00
Ken 02f0a911b0 🔥 Fjernet gammel kode for god praksis og sanity (#2866)
* 🔥 Fjernet filtrering av aksel_tema

* 🔥 Fjernet gamle migreringer

* ⚰️ Fjernet aksel_tema og gammelt feedback-dokument

* ⚰️ Fjernet gammelt god praksis card

* ♻️ Gjenbruker nytt gp-card for relaterte artikler

* ♻️ Fjernet gamle typer

* 🔥 Fjernet referanser til gammelt feedback-dokument
2024-04-22 15:12:56 +02:00
Ken ee8524229f 🎨 Temakontakter får slack-melding gjennom tilbakemeldinger (#2869) 2024-04-19 13:36:16 +02:00
Ken d9932e203a [Sanity] Temakontakter visning (#2868)
* 🎨 Lagt til Temakontakter-felt i for gp.tema

* 🔥 Fjernet requirement for temakontakter

* 🎨 Visning for temakontakter
2024-04-19 12:58:14 +02:00
KenandHalvor Haugan b14fc23ecb [react] Collapsible component utility (#2859)
* ✨ Boilerplate for collapsible-component

* 🎨 Collapsible wrapper interface

* 🎨 Collapsible trigger interface

* 🎨 Collapsible content interface

* 🧪 Stories

* ♻️ Satt opp storybook for chromatic

* 📝 Satt opp exports

* 🧪 Open/defaultOpen tester for chromatic

* 📝 Kommentert props

* ♻️ Definerer data-state verdi på root

* 📝 JSDOC

* 📝 Disabled-demo

* ♻️ aria-controls blir bare satt når open

* ♿ Content controls button

* Update @navikt/core/react/src/collapsible/Collapsible.types.ts

Co-authored-by: Halvor Haugan <83693529+HalvorHaugan@users.noreply.github.com>

* ♻️ props -> rest

* ♻️ props -> rest

* ♻️ Omit unreachable props

* 📝 _id rename

---------

Co-authored-by: Halvor Haugan <83693529+HalvorHaugan@users.noreply.github.com>
2024-04-19 12:27:55 +02:00
Ken c16f75d6f8 [Sanity] Temakontakter (#2867)
* 🎨 Lagt til Temakontakter-felt i for gp.tema

* 🔥 Fjernet requirement for temakontakter
2024-04-19 08:42:22 +02:00
Halvor Haugan a01be91608 Rekkefølge på eksempler på FormSummary 2024-04-18 13:05:21 +02:00
github-actions[bot]andgithub-actions[bot] 8826a79021 Version Packages (#2850)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-04-18 11:59:36 +02:00
Vegard HaugstvedtandKen c2f3da84cc Bugfix/prevent submit on enter in combobox (#2861)
* Prevent "Enter" key in Combobox to...

* Added test for Enter not submitting form

* Added changeset

* Add test for un-selecting a selected option using "enter" on a Chip not submitting the form

* Do not prevent enter from submitting form if it should not perform another action (like selecting an option)

* Do not submit when the input field has a value

* Replace lots of sleep-calls with awaiting userEvents

* Create util function for looking up an option

* Fix bug where we couldn't un-select a custom option using "enter"

* Update test to account for the dropdown list closing when focus moves to the chips in the previous step

* Add possibility to slow down tests for debugging

* Fix issue with onFocus -> onToggleSelected(false), where clicking the open/close button didn't work

* Fix test

* Closing the FilteredOptionsList when focusing a Chip caused too many side effects, but we need to remove virtual focus at least. That way we won't double-click on enter.

* When no longer closing FilteredOptions on focusing a chip, we don't need to click the toggleIsOpen button to check that the option is un-selected

* Update .changeset/rotten-windows-cover.md

Co-authored-by: Ken <26967723+KenAJoh@users.noreply.github.com>

---------

Co-authored-by: Ken <26967723+KenAJoh@users.noreply.github.com>
2024-04-18 10:00:16 +02:00
Ken 2c1476022d 🐛 Descendant-provider feilet ved bruk av cloneElement (#2832)
* 🐛 Når Descendant-provider ble brukt med react cloneElement feilet child-re-render

* ⚡ Synker options for allerede registrert node

* ⚡ Unngår flere map-lookups for samme funksjon
2024-04-18 09:55:34 +02:00
Halvor Haugan dacf648c47 FormSummary: Link til dokumentasjonen i JSDOC (#2865) 2024-04-18 09:06:29 +02:00
Ken 5ec986771b [God praksis] Nye sider erstatter gamle (#2858)
* 🚚 Byttet ut gamle god-praksis sider med nye

* ♻️ Sitemap bruker ny gp-struktur

* ♻️ Lister med tema tittler bruker nå nye tema

* ♻️ Flyttet gammel godpraksiscardsimple

* ⚰️ Markert gamle tema-felt i sanity som deprecated

* ♻️ Bedre håndtering av editor/profil visning

* ♻️ Velger nå ikke en default tab for sanity visning

* 🔒 ErrorBoundary på alle gp-cards

* 🧪 Storybook for frontpage-cards

* 🐛 Dialog var ikke riktig lablet

* 🐛 Chips-liste wrappet ikke button i li, cube-illustrasjon var ikke gjemt for skjermleser

* 🎉 Viser publiserings og oppdateringsdatoer i toppen av artikkel

* 📝 Bidragsytere -> Medvirkende

* 🐛 Playwright testet gamle god praksis sider

* 🐛 Preview brakk på forside og god-praksis

* 💄 HeroCards har større illustrasjon

* ♻️ Forenklet size på herocard

* ♻️ Innhold i herocard er alltid sentrert

* 🔍 Artikler har nå riktig fallback OG-image

* Update aksel.nav.no/website/e2e/test-urls.json

* 🏷️ bruker SanityImageSource for piktogram

* 🏷️ Tagget title i staticprops som deprecated

* 🔥 Fjernet testkode

* Update aksel.nav.no/website/sanity/plugins/structure/god-praksis.ts

* 📝 Skrivefeil i structure

* 💄 Oppdatert gap mellom tagger i cards
2024-04-18 07:28:08 +02:00
ca6af8a5f7 index & process templates -> stories (#2856)
* index & process templates -> stories

* ✨ add duplication of templates to component storybook

* 🎨 fix story

* ♻️ refactor indexers for storybook

* ♻️ layout fullscreen for templates

* 🔥 Fjernet meta.json filer

* ♻️ Trukket dekorator-kode ut av alle templates

* 🐛 Fjerner storybook stories fra npm output

* 🔥 Fjernet templates fra core/react

* 🔥 Fjernet console.log

---------

Co-authored-by: Ken <ken.aleksander@gmail.com>
Co-authored-by: Ken <26967723+KenAJoh@users.noreply.github.com>
2024-04-17 13:46:58 +02:00
Ken 9410840f74 🐛 Fikset lenke i sanity (#2863) 2024-04-17 13:22:42 +02:00
Ken d88884f785 🔥 Fjernet content-container.css og grid.css fra _mappings (#2860)
* 🔥 Fjernet content-container og grid css fra _mappings

* Update .changeset/wet-books-end.md
2024-04-17 09:46:27 +02:00
Ken 0c8239ee00 🐛 EditorPreview knapp Sanity admin 2024-04-16 12:48:06 +02:00
Halvor Haugan 987118fd47 FormSummary.Heading: Fjern overridable, level oblig. og css-justeringer på <sm (#2857) 2024-04-16 10:34:27 +02:00
8d13f11689 Summary component (docs tweaks) (#2853)
* 🚧 wip on Summary

* ✨ new HR component

* 👔 fancy utility function to inject components into children

This is to be used in 2 places where we iterate over children and add <hr /> components between them all.

It also wraps the children in a <Box>, which is used here and not really reflected in the name of the function 🤔

* 🎨 improved version of Summary component

* Remove (some) magic

* Fix children type in injectHRBetween

* css

* header gap and bg

* responsive story + example with long texts

* 🚚 Summary -> FormSummary

* 🎨 update stories

* 🎨 update story + tweak CSS for EditButton

* 📝 add JSDoc

* export FormSummary in core/react/src/index.ts

* FormSummaryValue bruker BodyLong

* 📝 update JSDoc

* 📝 add demo

* 📝 update README.md for eksempler

* forwardRef på Answers

* forwardRef på Header

* forwardRef på Label og Value

* forwardRef på Heading og Edit

* Answer export

* spread props

* 🔥 remove HR component

* 📝 update JSDoc

* 🎨 export more FormSummary

* 🔥 duplicate dependency of _mapping (same as main)

* 📝 update JSDoc for FormSummary

* changeset

* 🔖 changeset

* 🔥 duplicate changeset

* Rename EditButton -> Edit

* oops

* Link er vel valgfritt

* 🐛 add correct CSS dependency for FormSummary

* 💄 FormSummary

* 💄 FormSummary

* className navds-form-summary

* className navds-form-summary__answers

* className navds-form-summary__header

* Update @navikt/core/css/form/form-summary.css

Co-authored-by: Ken <26967723+KenAJoh@users.noreply.github.com>

* Update @navikt/core/react/src/form-summary/FormSummaryLabel.tsx

Co-authored-by: Ken <26967723+KenAJoh@users.noreply.github.com>

* Update @navikt/core/react/src/form-summary/FormSummaryValue.tsx

Co-authored-by: Ken <26967723+KenAJoh@users.noreply.github.com>

* Header wrapper på små skjermer

* Rekkefølgen på selectors samme som DOM

* 🎨 structure consistency (ref, then ...rest, then className, then other things)

- also convert a props -> {not_the_rest, ...rest}

* update mappings

* Flytt border slik at du kan ha flere dd etter hverandre

* 🎨 update stories

* 🎨 update stories (more empty values)

* 🚚 story ComplexData -> RealisticUsage

* Mindre padding på små skjermer

* 🎨 update stories

* Answer forwardRef

* 🎨 update stories

* Eksempel på linjeskift i value i story

* 🎨 update story

* 🎨 update story

* 🚚 FormSummary -> Form/FormSummary

* 🎨 update Demo and story

* 🏷️ make href required for FormSummaryEdit

* 🏷️ + 🚧 typing woes

* 💄 tweak CSS to make it look just right

* 🏷️ correct typing using Exclude > Omit

* 🎨 use div over section without label

* 💄 FormSummary, last-child padding removal

* Fjernet kommentar

* typo i readme

* 🚚 FormSummary.Edit -> FormSummary.EditLink

* 🏷️ make href for EditLink optional

* 🚚 rename FormSummaryEdit -> FormSummaryEditLink

* FormSummaryEdit -> FormSummaryEditLink

* ✨ add example to FormSummary

* 🎨 update examples

* 🎨 update examples

* 💄 FormSummary

* 🎨 types + jsdoc tweaks (FormSummary)

* 🔥 remove aria-label example

* 🎨 separate example for grouped data

* 🎨 update desc for example

* 🔥 unused example code

* 🚚 rename examples

---------

Co-authored-by: Halvor Haugan <halvor.haugan@nav.no>
Co-authored-by: Halvor Haugan <83693529+HalvorHaugan@users.noreply.github.com>
Co-authored-by: Ken <26967723+KenAJoh@users.noreply.github.com>
2024-04-15 13:54:08 +02:00
Ken 158e18454e 🚚 gp-sider lenker nå til god-praksis (#2852) 2024-04-15 13:53:40 +02:00
Ken be26bf22c9 🔥 Fjernet redirect (#2851) 2024-04-15 13:35:36 +02:00
Ken c2968a0e6d 🎨 Artikler med contributors får automatisk editor (#2849)
* 🎨 Artikler med contributors får automatisk editor lagt til ved creation

* 🔒 Lager bare profil automatisk for SSO-inlogging
2024-04-15 11:16:27 +02:00
KenandHalvor Haugan 3f92773273 [Sanity] Oppdatert deskStructure (#2844)
* 🚚 Flyttet bloggvisning til egen fil

* 🚚 Flyttet grunnleggende visning

* 🎨 Sorterer 'trenger oppdatering' etter eldst først

* 🎨 Sorterer 'trenger oppdatering' etter eldst først i god-praksis

* 🚚 Flyttet komponenter visning

* 🚚 Flyttet admin visning

* 💄 Endret ikon for utdaterte artikler. Dynamisk treshold for utdatert

* 📝 Skrivefeil

* 🐛 Skrivehjelp-tittel hadde for lav kontrast på darkmode i sanity

* 🔥 Fjernet unødvendig currenUser check

* ♻️ Fikset PR-kommentarer

* Update aksel.nav.no/website/sanity/plugins/structure/structure.util.ts

Co-authored-by: Halvor Haugan <83693529+HalvorHaugan@users.noreply.github.com>

* 🐛 Sjekker mail for lowercase

* 🐛 email kan være undefined

* Update aksel.nav.no/website/sanity/plugins/structure/structure.util.ts

Co-authored-by: Halvor Haugan <83693529+HalvorHaugan@users.noreply.github.com>

* ♻️ Bruker innebygd showIcon-funksjon

* 📝 Bedre gruppenavn for seksjoner i sanity

* 💄 Ikoner på landigssider

---------

Co-authored-by: Halvor Haugan <83693529+HalvorHaugan@users.noreply.github.com>
2024-04-15 11:15:17 +02:00
Ken 0c3ab1d0e7 [Sanity] Oppdatert utdatertvarsel (#2846)
* 🎨 innholdstype og undertema er nå required

* ♻️ Bruker eget panel for utdatert-warning i sanity

* ⚡ Bruker sanity useCurrentUser for å vise tilganger til bruker på profilside

* Update aksel.nav.no/website/sanity/schema/custom-components/EditorPage.tsx

* 🔥 Fjernet rest-spread
2024-04-15 11:12:32 +02:00
3a73a029f6 ✨ new FormSummary component (#2802)
* 🚧 wip on Summary

* ✨ new HR component

* 👔 fancy utility function to inject components into children

This is to be used in 2 places where we iterate over children and add <hr /> components between them all.

It also wraps the children in a <Box>, which is used here and not really reflected in the name of the function 🤔

* 🎨 improved version of Summary component

* Remove (some) magic

* Fix children type in injectHRBetween

* css

* header gap and bg

* responsive story + example with long texts

* 🚚 Summary -> FormSummary

* 🎨 update stories

* 🎨 update story + tweak CSS for EditButton

* 📝 add JSDoc

* export FormSummary in core/react/src/index.ts

* FormSummaryValue bruker BodyLong

* 📝 update JSDoc

* 📝 add demo

* 📝 update README.md for eksempler

* forwardRef på Answers

* forwardRef på Header

* forwardRef på Label og Value

* forwardRef på Heading og Edit

* Answer export

* spread props

* 🔥 remove HR component

* 📝 update JSDoc

* 🎨 export more FormSummary

* 🔥 duplicate dependency of _mapping (same as main)

* 📝 update JSDoc for FormSummary

* changeset

* 🔖 changeset

* 🔥 duplicate changeset

* Rename EditButton -> Edit

* oops

* Link er vel valgfritt

* 🐛 add correct CSS dependency for FormSummary

* 💄 FormSummary

* 💄 FormSummary

* className navds-form-summary

* className navds-form-summary__answers

* className navds-form-summary__header

* Update @navikt/core/css/form/form-summary.css

Co-authored-by: Ken <26967723+KenAJoh@users.noreply.github.com>

* Update @navikt/core/react/src/form-summary/FormSummaryLabel.tsx

Co-authored-by: Ken <26967723+KenAJoh@users.noreply.github.com>

* Update @navikt/core/react/src/form-summary/FormSummaryValue.tsx

Co-authored-by: Ken <26967723+KenAJoh@users.noreply.github.com>

* Header wrapper på små skjermer

* Rekkefølgen på selectors samme som DOM

* 🎨 structure consistency (ref, then ...rest, then className, then other things)

- also convert a props -> {not_the_rest, ...rest}

* update mappings

* Flytt border slik at du kan ha flere dd etter hverandre

* 🎨 update stories

* 🎨 update stories (more empty values)

* 🚚 story ComplexData -> RealisticUsage

* Mindre padding på små skjermer

* 🎨 update stories

* Answer forwardRef

* 🎨 update stories

* Eksempel på linjeskift i value i story

* 🎨 update story

* 🎨 update story

* 🚚 FormSummary -> Form/FormSummary

* 🎨 update Demo and story

* 🏷️ make href required for FormSummaryEdit

* 🏷️ + 🚧 typing woes

* 💄 tweak CSS to make it look just right

* 🏷️ correct typing using Exclude > Omit

* 🎨 use div over section without label

* 💄 FormSummary, last-child padding removal

* Fjernet kommentar

* typo i readme

* 🚚 FormSummary.Edit -> FormSummary.EditLink

* 🏷️ make href for EditLink optional

* 🚚 rename FormSummaryEdit -> FormSummaryEditLink

* FormSummaryEdit -> FormSummaryEditLink

* ✨ add example to FormSummary

* 🎨 update examples

* 🎨 update examples

* 💄 FormSummary

* 🎨 types + jsdoc tweaks (FormSummary)

---------

Co-authored-by: Halvor Haugan <halvor.haugan@nav.no>
Co-authored-by: Halvor Haugan <83693529+HalvorHaugan@users.noreply.github.com>
Co-authored-by: Ken <26967723+KenAJoh@users.noreply.github.com>
2024-04-15 09:53:42 +02:00
Halvor Haugan 6fd0f501f5 Story med forced-colors på Button (#2847) 2024-04-15 09:24:07 +02:00
Ken a11b8ddf4e 🐛 Dokumentert bruk av full og overflow-x av bleed 2024-04-12 14:51:45 +02:00
Ken 171db6a813 [Sanity] Flyttet dev-område for ny god-praksis inn i "prod" (#2843)
* 🚚 Flyttet gp-taxonomy over til prod

* 🎉 Satt opp ny sanity-struktur for god-praksis

* 🔥 Fjernet gammel editor-visning

* 🎉 Oppdatert templates for ny sanity-visning av gp

* 🔥 Fjernet god-praksis-taxonomy struktur

Struktur er nå flyttet inn i main
2024-04-12 08:25:00 +02:00
132 changed files with 3779 additions and 3549 deletions

No files matched your search

+4
View File
@@ -1,5 +1,9 @@
# @navikt/aksel-icons
## 6.6.1
## 6.6.0
## 6.5.0
## 6.4.1
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/aksel-icons",
"version": "6.5.0",
"version": "6.6.1",
"author": "Aksel | NAV",
"homepage": "https://aksel.nav.no/ikoner",
"repository": {
+4
View File
@@ -1,5 +1,9 @@
# @navikt/aksel-stylelint
## 6.6.1
## 6.6.0
## 6.5.0
## 6.4.1
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/aksel-stylelint",
"version": "6.5.0",
"version": "6.6.1",
"author": "Aksel | NAV",
"homepage": "https://aksel.nav.no/grunnleggende/kode/stylelint",
"repository": {
@@ -35,8 +35,8 @@
"dev": "yarn watch:lint"
},
"devDependencies": {
"@navikt/ds-css": "^6.5.0",
"@navikt/ds-tokens": "^6.5.0",
"@navikt/ds-css": "^6.6.1",
"@navikt/ds-tokens": "^6.6.1",
"concurrently": "7.2.1",
"postcss-selector-parser": "^6.0.13",
"postcss-value-parser": "^4.2.0",
+16
View File
@@ -1,5 +1,21 @@
# @navikt/aksel
## 6.6.1
### Patch Changes
- Updated dependencies []:
- @navikt/ds-css@6.6.1
## 6.6.0
### Patch Changes
- Aksel CLI: Fjernet deprecated komponenter fra css oversikt. ([#2860](https://github.com/navikt/aksel/pull/2860))
- Updated dependencies [[`3a73a029f`](https://github.com/navikt/aksel/commit/3a73a029f6272f76e10cb6baefbaa2040feaef6b)]:
- @navikt/ds-css@6.6.0
## 6.5.0
### Patch Changes
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/aksel",
"version": "6.5.0",
"version": "6.6.1",
"description": "Aksel command line interface. Handles css-imports, codemods and more",
"author": "Aksel | NAV designsystem team",
"license": "MIT",
@@ -29,7 +29,7 @@
},
"homepage": "https://aksel.nav.no/grunnleggende/kode/kommandolinje",
"dependencies": {
"@navikt/ds-css": "6.5.0",
"@navikt/ds-css": "6.6.1",
"axios": "1.6.0",
"chalk": "4.1.0",
"clipboardy": "^2.3.0",
+8
View File
@@ -1,5 +1,13 @@
# @navikt/ds-css
## 6.6.1
## 6.6.0
### Minor Changes
- :sparkles: Ny komponent FormSummary ([#2802](https://github.com/navikt/aksel/pull/2802))
## 6.5.0
## 6.4.1
+5 -5
View File
@@ -97,10 +97,6 @@ const StyleMappings = {
main: formCss,
dependencies: [typoCss],
},
{
component: "ContentContainer",
main: "content-container.css",
},
{
component: "CopyButton",
main: "copybutton.css",
@@ -125,7 +121,6 @@ const StyleMappings = {
main: formCss,
dependencies: [typoCss, "button.css", "loader.css", "link.css"],
},
{ component: "Grid", main: "grid.css", dependencies: [typoCss] },
{
component: "GuidePanel",
main: "guide-panel.css",
@@ -178,6 +173,11 @@ const StyleMappings = {
{ component: "Tag", main: "tag.css", dependencies: [typoCss] },
{ component: "TextField", main: formCss, dependencies: [typoCss] },
{ component: "Textarea", main: formCss, dependencies: [typoCss] },
{
component: "FormSummary",
main: formCss,
dependencies: [typoCss, "link.css"],
},
{
component: "ToggleGroup",
main: "toggle-group.css",
+77
View File
@@ -0,0 +1,77 @@
.navds-form-summary {
overflow: hidden;
border: 1px solid var(--a-border-subtle);
border-radius: var(--a-border-radius-large);
}
.navds-form-summary__header {
background-color: var(--a-surface-subtle);
padding: var(--a-spacing-4) var(--a-spacing-6);
display: flex;
justify-content: space-between;
gap: 0 var(--a-spacing-2);
}
@media (max-width: 479px) {
.navds-form-summary__header {
padding: var(--a-spacing-3) var(--a-spacing-4);
flex-direction: column;
}
}
.navds-form-summary__edit {
flex-shrink: 0;
margin-top: var(--a-spacing-1);
align-self: flex-start;
}
.navds-form-summary > .navds-form-summary__answers {
border-top: 1px solid var(--a-border-subtle);
}
.navds-form-summary__answers {
padding: var(--a-spacing-5) var(--a-spacing-6) var(--a-spacing-6);
margin: 0;
}
@media (max-width: 479px) {
.navds-form-summary__answers {
padding: var(--a-spacing-4);
}
}
.navds-form-summary__answer:not(:last-child) {
margin-bottom: var(--a-spacing-4);
padding-bottom: var(--a-spacing-4);
border-bottom: 1px solid var(--a-border-divider);
}
.navds-form-summary__answer:has(.navds-form-summary__answer) {
padding-bottom: var(--a-spacing-6);
}
.navds-form-summary__answer:has(.navds-form-summary__answer):last-child {
padding-bottom: 0;
}
@media (max-width: 479px) {
.navds-form-summary__answer:not(:last-child) {
margin-bottom: var(--a-spacing-3);
padding-bottom: var(--a-spacing-3);
}
.navds-form-summary__answer:has(.navds-form-summary__answer) {
padding-bottom: var(--a-spacing-5);
}
}
.navds-form-summary__value:first-of-type {
margin-top: var(--a-spacing-1);
}
.navds-form-summary__value :where(.navds-form-summary__answers) {
margin-top: var(--a-spacing-2);
padding: var(--a-spacing-4);
background: var(--a-surface-selected);
border-radius: var(--a-border-radius-large);
}
+1
View File
@@ -13,3 +13,4 @@
@import "textarea.css";
@import "search.css";
@import "combobox.css";
@import "form-summary.css";
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/ds-css",
"version": "6.5.0",
"version": "6.6.1",
"description": "CSS for NAV Designsystem",
"author": "Aksel | NAV designsystem team",
"keywords": [
@@ -27,7 +27,7 @@
"css:get-version": "node config/get-version.js"
},
"devDependencies": {
"@navikt/ds-tokens": "^6.5.0",
"@navikt/ds-tokens": "^6.6.1",
"cssnano": "6.0.0",
"fast-glob": "3.2.11",
"lodash": "4.17.21",
+24
View File
@@ -1,5 +1,29 @@
# @navikt/ds-react
## 6.6.1
### Patch Changes
- Spacer: Er nå `span` (tidligere `div`). ([#2874](https://github.com/navikt/aksel/pull/2874))
- Updated dependencies []:
- @navikt/ds-tokens@6.6.1
- @navikt/aksel-icons@6.6.1
## 6.6.0
### Minor Changes
- :sparkles: Ny komponent FormSummary ([#2802](https://github.com/navikt/aksel/pull/2802))
### Patch Changes
- Combobox: Prevents "Enter" while Combobox is focused from submitting form. ([#2861](https://github.com/navikt/aksel/pull/2861))
- Updated dependencies []:
- @navikt/ds-tokens@6.6.0
- @navikt/aksel-icons@6.6.0
## 6.5.0
### Minor Changes
+15 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/ds-react",
"version": "6.5.0",
"version": "6.6.1",
"description": "React components from the Norwegian Labour and Welfare Administration.",
"author": "Aksel, a team part of the Norwegian Labour and Welfare Administration.",
"license": "MIT",
@@ -24,7 +24,8 @@
"/cjs",
"/esm",
"/src",
"!**/*.tsbuildinfo"
"!**/*.tsbuildinfo",
"!**/*.stories.*"
],
"main": "./cjs/index.js",
"module": "./esm/index.js",
@@ -549,6 +550,16 @@
"default": "./cjs/form/file-upload/index.js"
}
},
"./FormSummary": {
"import": {
"types": "./esm/form/form-summary/index.d.ts",
"default": "./esm/form/form-summary/index.js"
},
"require": {
"types": "./cjs/form/form-summary/index.d.ts",
"default": "./cjs/form/form-summary/index.js"
}
},
"./package.json": "./package.json"
},
"scripts": {
@@ -562,8 +573,8 @@
},
"dependencies": {
"@floating-ui/react": "0.25.4",
"@navikt/aksel-icons": "^6.5.0",
"@navikt/ds-tokens": "^6.5.0",
"@navikt/aksel-icons": "^6.6.1",
"@navikt/ds-tokens": "^6.6.1",
"clsx": "^2.1.0",
"date-fns": "^3.0.0",
"react-day-picker": "8.10.0"
@@ -183,3 +183,10 @@ export const Chromatic: Story = {
chromatic: { disable: false },
},
};
export const ChromaticForcedColors: Story = {
render: Chromatic.render,
parameters: {
chromatic: { disable: false, forcedColors: "active" },
},
};
@@ -0,0 +1,32 @@
import { createContext } from "../util/create-context";
import { CollapsibleBaseProps } from "./Collapsible.types";
export type CollapsibleContextProps = Pick<CollapsibleBaseProps, "open"> & {
/**
* Allows connecting trigger to content
*/
contentId: string;
/**
* Allows connecting content to trigger
*/
triggerId: string;
/**
* Callback for toggling open state
*/
onOpenToggle: () => void;
/**
* Allows always rendering children in content when closed
*/
lazy: boolean;
/**
* Utility for getting state as string. Usefull for data-attributes.
*/
state: "open" | "closed";
};
export const [CollapsibleContextProvider, useCollapsibleContext] =
createContext<CollapsibleContextProps>({
name: "CollapsibleContext",
errorMessage:
"<Collapsible.Trigger> and <Collapsible.Content> must be used within a <Collapsible>",
});
@@ -0,0 +1,222 @@
import { Meta, StoryFn, StoryObj } from "@storybook/react";
import React, { useState } from "react";
import { ChevronDownIcon, ChevronUpIcon } from "@navikt/aksel-icons";
import { Button } from "../button";
import { Box } from "../layout/box";
import { HStack, VStack } from "../layout/stack";
import { Collapsible } from "./Collapsible";
const meta: Meta<typeof Collapsible> = {
title: "Utilities/Collapsible",
component: Collapsible,
parameters: {
chromatic: { disable: true },
},
};
export default meta;
type Story = StoryObj<typeof Collapsible>;
export const Default: Story = {
render: (...props) => (
<Collapsible {...props}>
<Collapsible.Trigger>Trigger</Collapsible.Trigger>
<Collapsible.Content>
Lorem ipsum dolor sit amet consectetur adipisicing elit. Recusandae
corporis maxime aliquam, voluptates nobis numquam, non odit optio
architecto iure laborum possimus! Quibusdam sit ullam, consequatur sunt
tempore optio aliquid!
</Collapsible.Content>
</Collapsible>
),
args: {
open: false,
defaultOpen: false,
},
};
export const InContext: StoryFn<typeof Collapsible> = () => {
const [open, setOpen] = useState(false);
return (
<Collapsible open={open} onOpenChange={setOpen}>
<HStack justify="space-between" gap="4" align="center">
Steg 1 av 8{" "}
<Collapsible.Trigger asChild>
<Button
size="small"
variant="tertiary"
iconPosition="right"
icon={
open ? (
<ChevronUpIcon aria-hidden />
) : (
<ChevronDownIcon aria-hidden />
)
}
>
Vis steg
</Button>
</Collapsible.Trigger>
</HStack>
<Collapsible.Content>
<p>Lorem ipsum dolor sit</p>
</Collapsible.Content>
</Collapsible>
);
};
export const Animated: StoryFn<typeof Collapsible> = () => (
<Collapsible lazy>
<Collapsible.Trigger asChild>
<Button size="small" variant="secondary">
Animer open/lukk
</Button>
</Collapsible.Trigger>
<Collapsible.Content className="collapsible">
<div className="collapsibleContent">
<Box padding="8" background="surface-alt-3-subtle">
<div>lorem ipsum</div>
</Box>
</div>
</Collapsible.Content>
</Collapsible>
);
Animated.decorators = [
(Story) => (
<div style={{ height: 300 }}>
<style>{`
.collapsible {
display: grid;
grid-template-rows: 0fr;
overflow: hidden;
transition: grid-template-rows 1s ease-in-out;
}
.collapsible[data-state="closed"] {
width: 0;
}
.collapsible[data-state="open"] {
grid-template-rows: 1fr;
}
.collapsibleContent {
min-height: 0;
transition: visibility 1s;
visibility: hidden;
}
.collapsible[data-state="open"] .collapsibleContent {
visibility: visible;
}
`}</style>
{Story()}
</div>
),
];
export const Lazy: StoryFn<typeof Collapsible> = () => (
<Collapsible lazy>
<Collapsible.Trigger>Trigger</Collapsible.Trigger>
<Collapsible.Content>
Lorem ipsum dolor sit amet consectetur adipisicing elit. Recusandae
corporis maxime aliquam, voluptates nobis numquam, non odit optio
architecto iure laborum possimus! Quibusdam sit ullam, consequatur sunt
tempore optio aliquid!
</Collapsible.Content>
</Collapsible>
);
export const AsChild: StoryFn<typeof Collapsible> = () => (
<Collapsible>
<Collapsible.Trigger asChild>
<Button>Button</Button>
</Collapsible.Trigger>
<Collapsible.Content asChild>
<Box padding="4" background="surface-alt-3-subtle">
<div>lorem ipsum</div>
</Box>
</Collapsible.Content>
</Collapsible>
);
export const DefaultOpen: StoryFn<typeof Collapsible> = () => (
<Collapsible defaultOpen>
<Collapsible.Trigger asChild>
<Button>Button</Button>
</Collapsible.Trigger>
<Collapsible.Content asChild>
<Box padding="4" background="surface-alt-3-subtle">
<div>lorem ipsum</div>
</Box>
</Collapsible.Content>
</Collapsible>
);
export const ControlledOpen: StoryFn<typeof Collapsible> = () => (
<Collapsible open>
<Collapsible.Trigger asChild>
<Button>Button</Button>
</Collapsible.Trigger>
<Collapsible.Content asChild>
<Box padding="4" background="surface-alt-3-subtle">
<div>lorem ipsum</div>
</Box>
</Collapsible.Content>
</Collapsible>
);
export const Disabled: StoryFn<typeof Collapsible> = ({ open = false }) => (
<Collapsible open={open}>
<Collapsible.Trigger asChild disabled>
<Button>Button</Button>
</Collapsible.Trigger>
<Collapsible.Content asChild>
<Box padding="4" background="surface-alt-3-subtle">
<div>lorem ipsum</div>
</Box>
</Collapsible.Content>
</Collapsible>
);
export const Chromatic: Story = {
render: () => (
<VStack gap="8">
<div>
<h2>In context</h2>
<InContext />
</div>
<div>
<h2>Animated</h2>
<Animated />
</div>
<div>
<h2>Lazy</h2>
<Lazy />
</div>
<div>
<h2>AsChild</h2>
<AsChild />
</div>
<div>
<h2>DefaultOpen</h2>
<DefaultOpen />
</div>
<div>
<h2>ControlledOpen</h2>
<ControlledOpen />
</div>
<div>
<h2>Disabled</h2>
<Disabled />
<Disabled open />
</div>
</VStack>
),
parameters: {
chromatic: { disable: false },
},
};
@@ -0,0 +1,100 @@
import React, { forwardRef, useCallback } from "react";
import { useControllableState, useId } from "../util/hooks";
import { CollapsibleContextProvider } from "./Collapsible.context";
import { CollapsibleBaseProps } from "./Collapsible.types";
import CollapsibleContent from "./parts/Collapsible.Content";
import CollapsibleTrigger from "./parts/Collapsible.Trigger";
export interface CollapsibleProps
extends CollapsibleBaseProps,
React.HTMLAttributes<HTMLDivElement> {}
interface CollapsibleComponent
extends React.ForwardRefExoticComponent<
CollapsibleProps & React.RefAttributes<HTMLDivElement>
> {
/**
* @see 🏷️ {@link CollapsibleTriggerProps}
*/
Trigger: typeof CollapsibleTrigger;
/**
* @see 🏷️ {@link CollapsibleContentProps}
*/
Content: typeof CollapsibleContent;
}
/**
* Collapsible is a component that allows you to toggle visibility of content.
*
* @example
* ```jsx
* <Collapsible>
* <Collapsible.Trigger>Trigger</Collapsible.Trigger>
* <Collapsible.Content>
* Lorem ipsum dolor sit amet consectetur adipisicing elit. Recusandae
* corporis maxime aliquam, voluptates nobis numquam, non odit optio
* architecto iure laborum possimus! Quibusdam sit ullam, consequatur sunt
* tempore optio aliquid!
* </Collapsible.Content>
* </Collapsible>
* ```
*
* @example With asChild
* ```jsx
* <Collapsible>
* <Collapsible.Trigger asChild>
* <Button>Button</Button>
* </Collapsible.Trigger>
* <Collapsible.Content asChild>
* <Box padding="4" background="surface-alt-3-subtle">
* <div>lorem ipsum</div>
* </Box>
* </Collapsible.Content>
* </Collapsible>
* ```
*/
export const Collapsible = forwardRef<HTMLDivElement, CollapsibleProps>(
(
{
children,
open,
defaultOpen = false,
onOpenChange,
lazy = false,
...rest
},
ref,
) => {
const [_open, setOpen] = useControllableState({
value: open,
defaultValue: defaultOpen,
onChange: onOpenChange,
});
const internalId = useId();
const state = _open ? "open" : "closed";
return (
<CollapsibleContextProvider
open={_open}
onOpenToggle={useCallback(
() => setOpen((prevOpen) => !prevOpen),
[setOpen],
)}
contentId={`collapsible-content-${internalId}`}
triggerId={`collapsible-trigger-${internalId}`}
lazy={lazy}
state={state}
>
<div ref={ref} data-state={state} {...rest}>
{children}
</div>
</CollapsibleContextProvider>
);
},
) as CollapsibleComponent;
Collapsible.Trigger = CollapsibleTrigger;
Collapsible.Content = CollapsibleContent;
export default Collapsible;
@@ -0,0 +1,19 @@
export interface CollapsibleBaseProps {
/**
* The open state of the collapsible when it is initially rendered. Use when you do not need to control its open state.
*/
defaultOpen?: boolean;
/**
* The controlled open state of the collapsible. Must be used in conjunction with onOpenChange.
*/
open?: boolean;
/**
* Event handler called when the open state of the collapsible changes.
*/
onOpenChange?: (open: boolean) => void;
/**
* Render the collapsible content lazily. This means that the content will not be rendered until the collapsible is open.
* @default false
*/
lazy?: boolean;
}
@@ -0,0 +1,10 @@
"use client";
export { default as Collapsible, type CollapsibleProps } from "./Collapsible";
export {
default as CollapsibleTrigger,
type CollapsibleTriggerProps,
} from "./parts/Collapsible.Trigger";
export {
default as CollapsibleContent,
type CollapsibleContentProps,
} from "./parts/Collapsible.Content";
@@ -0,0 +1,39 @@
import React, { forwardRef } from "react";
import { Slot } from "../../util/Slot";
import { useCollapsibleContext } from "../Collapsible.context";
export interface CollapsibleContentProps
extends Omit<
React.HTMLAttributes<HTMLDivElement>,
"hidden" | "aria-controls" | "id"
> {
/**
* When true, will render element as its child. This merges classes, styles and event handlers.
* @default false
*/
asChild?: boolean;
}
export const CollapsibleContent = forwardRef<
HTMLDivElement,
CollapsibleContentProps
>(({ children, asChild, ...rest }, ref) => {
const ctx = useCollapsibleContext();
const Comp = asChild ? Slot : "div";
return (
<Comp
ref={ref}
{...rest}
data-state={ctx.state}
hidden={!ctx.open}
aria-controls={ctx.open ? ctx.triggerId : undefined}
id={ctx.contentId}
>
{ctx.lazy || ctx.open ? children : null}
</Comp>
);
});
export default CollapsibleContent;
@@ -0,0 +1,42 @@
import React, { forwardRef } from "react";
import { Slot } from "../../util/Slot";
import { composeEventHandlers } from "../../util/composeEventHandlers";
import { useCollapsibleContext } from "../Collapsible.context";
export interface CollapsibleTriggerProps
extends Omit<
React.ButtonHTMLAttributes<HTMLButtonElement>,
"id" | "aria-controls" | "aria-expanded"
> {
/**
* When true, will render element as its child. This merges classes, styles and event handlers.
* @default false
*/
asChild?: boolean;
}
export const CollapsibleTrigger = forwardRef<
HTMLButtonElement,
CollapsibleTriggerProps
>(({ children, asChild, onClick, ...rest }, ref) => {
const ctx = useCollapsibleContext();
const Comp = asChild ? Slot : "button";
return (
<Comp
ref={ref}
type="button"
data-state={ctx.state}
onClick={composeEventHandlers(onClick, ctx.onOpenToggle)}
{...rest}
id={ctx.triggerId}
aria-controls={ctx.open ? ctx.contentId : undefined}
aria-expanded={ctx.open}
>
{children}
</Comp>
);
});
export default CollapsibleTrigger;
@@ -97,9 +97,17 @@ export const FilteredOptionsProvider = ({
[filteredOptionsUtils.getAddNewOptionId(id)]: allowNewValues
? toComboboxOption(value)
: undefined,
...customOptions.reduce(
(acc, customOption) => ({
...acc,
[filteredOptionsUtils.getOptionId(id, customOption.label)]:
customOption,
}),
{},
),
},
),
[allowNewValues, id, options, value],
[allowNewValues, customOptions, id, options, value],
);
useClientLayoutEffect(() => {
@@ -128,6 +128,10 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
removeSelectedOption(lastSelectedOption);
}
}
} else if (e.key === "Enter" || e.key === "Accept") {
if (activeDecendantId || value) {
e.preventDefault();
}
} else if (e.key === "ArrowDown") {
// Check that cursor position is at the end of the input field,
// so we don't interfere with text editing
@@ -182,6 +186,7 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
{...omit(inputProps, ["aria-invalid"])}
ref={ref}
value={value}
onBlur={() => virtualFocus.moveFocusToTop()}
onChange={onChangeHandler}
type="text"
role="combobox"
@@ -624,3 +624,71 @@ export const TestHoverAndFocusSwitching: StoryObject = {
);
},
};
export const TestEnterNotSubmittingForm: StoryObj<{
onSubmit: ReturnType<typeof fn>;
}> = {
args: {
onSubmit: fn(),
},
render: ({ onSubmit }) => {
return (
<form action="https://www.aksel.nav.no" method="get" onSubmit={onSubmit}>
<UNSAFE_Combobox
options={options}
label="Hva er dine favorittfrukter?"
isMultiSelect
allowNewValues
/>
</form>
);
},
play: async ({ canvasElement, args }) => {
args.onSubmit.mockClear();
const canvas = within(canvasElement);
const waitTime = 0; // Change for debugging
await sleep(waitTime);
const getInput = () =>
canvas.getByRole("combobox", {
name: "Hva er dine favorittfrukter?",
});
const getOption = (name: string, selected: boolean) =>
canvas.getByRole("option", { name, selected });
await userEvent.click(getInput(), { delay: waitTime });
await userEvent.keyboard("{ArrowDown}", { delay: waitTime });
expect(getInput().getAttribute("aria-activedescendant")).toBe(
getOption("banana", false).getAttribute("id"),
);
await userEvent.keyboard("{Enter}", { delay: waitTime });
expect(args.onSubmit).not.toHaveBeenCalled();
expect(getOption("banana", true)).toBeVisible();
await userEvent.keyboard("{Shift>}{Tab}", { delay: waitTime });
await userEvent.keyboard("{Enter}", { delay: waitTime });
expect(args.onSubmit).not.toHaveBeenCalled();
await userEvent.keyboard("test"); // Type option that does not exist
await userEvent.keyboard("{Enter}", { delay: waitTime });
expect(args.onSubmit).not.toHaveBeenCalled();
await userEvent.keyboard("{ArrowDown}", { delay: waitTime }); // Select "test" custom option
expect(
canvas.getByRole("option", { name: "test", selected: true }),
).toBeVisible();
await userEvent.keyboard("{Enter}", { delay: waitTime }); // De-select "test"
expect(
canvas.queryByRole("option", { name: "test", selected: false }),
).toBeNull();
expect(args.onSubmit).not.toHaveBeenCalled();
await userEvent.keyboard("{Escape}", { delay: waitTime }); // Clear input field
await userEvent.keyboard("{Enter}", { delay: waitTime }); // Enter on empty Input with closed list
expect(args.onSubmit).toHaveBeenCalledOnce();
},
};
@@ -0,0 +1,388 @@
import { Meta, StoryFn, StoryObj } from "@storybook/react";
import React from "react";
import { LinkIcon } from "@navikt/aksel-icons";
import { VStack } from "../../layout/stack";
import { Link } from "../../link";
import FormSummary from "./FormSummary";
const meta: Meta<typeof FormSummary> = {
title: "ds-react/FormSummary",
component: FormSummary,
decorators: [
(story) => (
<div style={{ width: 600, maxWidth: "100%", margin: "0 auto" }}>
{story()}
</div>
),
],
parameters: { layout: "padded", chromatic: { disable: true } },
};
export default meta;
export const Default: StoryFn<typeof FormSummary> = () => (
<FormSummary>
<FormSummary.Header>
<FormSummary.Heading level="2">Personalia</FormSummary.Heading>
<FormSummary.EditLink href="#" />
</FormSummary.Header>
<FormSummary.Answers>
<FormSummary.Answer>
<FormSummary.Label>Navn</FormSummary.Label>
<FormSummary.Value>Ola Nordmann</FormSummary.Value>
</FormSummary.Answer>
<FormSummary.Answer>
<FormSummary.Label>Adresse</FormSummary.Label>
<FormSummary.Value>
Gate 123
<br />
1234 Sted
</FormSummary.Value>
</FormSummary.Answer>
<FormSummary.Answer>
<FormSummary.Label>Fødselsnummer</FormSummary.Label>
<FormSummary.Value>12345678910</FormSummary.Value>
</FormSummary.Answer>
<FormSummary.Answer>
<FormSummary.Label>Barn nr. 1</FormSummary.Label>
<FormSummary.Value>
<FormSummary.Answers>
<FormSummary.Answer>
<FormSummary.Label>Navn</FormSummary.Label>
<FormSummary.Value>Kari Nordmann</FormSummary.Value>
</FormSummary.Answer>
<FormSummary.Answer>
<FormSummary.Label>Kjønn</FormSummary.Label>
<FormSummary.Value>Jente</FormSummary.Value>
</FormSummary.Answer>
<FormSummary.Answer>
<FormSummary.Label>Alder</FormSummary.Label>
<FormSummary.Value>6</FormSummary.Value>
</FormSummary.Answer>
</FormSummary.Answers>
</FormSummary.Value>
</FormSummary.Answer>
<FormSummary.Answer>
<FormSummary.Label>Barn nr. 2</FormSummary.Label>
<FormSummary.Value>
<FormSummary.Answers>
<FormSummary.Answer>
<FormSummary.Label>Navn</FormSummary.Label>
<FormSummary.Value>Per Nordmann</FormSummary.Value>
</FormSummary.Answer>
<FormSummary.Answer>
<FormSummary.Label>Kjønn</FormSummary.Label>
<FormSummary.Value>Gutt</FormSummary.Value>
</FormSummary.Answer>
<FormSummary.Answer>
<FormSummary.Label>Alder</FormSummary.Label>
<FormSummary.Value>7</FormSummary.Value>
</FormSummary.Answer>
</FormSummary.Answers>
</FormSummary.Value>
</FormSummary.Answer>
<FormSummary.Answer>
<FormSummary.Label>Kontaktinformasjon</FormSummary.Label>
<FormSummary.Value>
<Link>Fyll ut kontaktinformasjon</Link>
</FormSummary.Value>
</FormSummary.Answer>
<FormSummary.Answer>
<FormSummary.Label>Hvordan vil du bli varslet?</FormSummary.Label>
<FormSummary.Value>E-post</FormSummary.Value>
<FormSummary.Value>SMS</FormSummary.Value>
</FormSummary.Answer>
</FormSummary.Answers>
</FormSummary>
);
export const LongTexts: StoryFn<typeof FormSummary> = () => (
<FormSummary>
<FormSummary.Header>
<FormSummary.Heading level="2">
Arbeidsforhold som du har i eller utenfor EØS-området
</FormSummary.Heading>
<FormSummary.EditLink href="#">
<LinkIcon />
Jeg vil endre dette svaret
</FormSummary.EditLink>
</FormSummary.Header>
<FormSummary.Answers>
<FormSummary.Answer>
<FormSummary.Label>
Har du jobbet i et annet EØS-land, Sveits eller Storbritannia i løpet
av de siste 36 månedene? Om ja, hvilket land og hvor lenge jobbet du
der? Kan du fortelle litt om hva du jobbet med?
</FormSummary.Label>
<FormSummary.Value>
Nei, jeg har ikke jobbet i et annet EØS-land, Sveits eller
Storbritannia i løpet av de siste 36 månedene. Og jeg har heller ikke
jobbet i Norge i løpet av de siste 36 månedene. Men jeg har jobbet i
et annet land utenfor EØS-området. Det var i Sør-Korea, og jeg jobbet
som lærer på en internasjonal skole. Jeg jobbet der i 12 måneder, og
det var en veldig spennende opplevelse. Jeg lærte mye om koreansk
kultur og språk, og jeg fikk mange nye venner. Jeg savner Sør-Korea
veldig mye, og jeg håper å kunne dra tilbake.
<FormSummary.Answers>
<FormSummary.Answer>
<FormSummary.Label>
Har du jobbet i et annet EØS-land, Sveits eller Storbritannia i
løpet av de siste 36 månedene? Om ja, hvilket land og hvor lenge
jobbet du der? Kan du fortelle litt om hva du jobbet med?
</FormSummary.Label>
<FormSummary.Value>
Nei, jeg har ikke jobbet i et annet EØS-land, Sveits eller
Storbritannia i løpet av de siste 36 månedene. Og jeg har heller
ikke jobbet i Norge i løpet av de siste 36 månedene. Men jeg har
jobbet i et annet land utenfor EØS-området. Det var i Sør-Korea,
og jeg jobbet som lærer på en internasjonal skole. Jeg jobbet
der i 12 måneder, og det var en veldig spennende opplevelse. Jeg
lærte mye om koreansk kultur og språk, og jeg fikk mange nye
venner. Jeg savner Sør-Korea veldig mye, og jeg håper å kunne
dra tilbake.
</FormSummary.Value>
</FormSummary.Answer>
</FormSummary.Answers>
</FormSummary.Value>
</FormSummary.Answer>
</FormSummary.Answers>
</FormSummary>
);
export const NoLink: StoryFn<typeof FormSummary> = () => (
<FormSummary>
<FormSummary.Header>
<FormSummary.Heading level="2">
Arbeidsforhold som du har i eller utenfor EØS-området
</FormSummary.Heading>
</FormSummary.Header>
<FormSummary.Answers>
<FormSummary.Answer>
<FormSummary.Label>Navn</FormSummary.Label>
<FormSummary.Value>Ola Nordmann</FormSummary.Value>
</FormSummary.Answer>
<FormSummary.Answer>
<FormSummary.Label>Fødselsnummer</FormSummary.Label>
<FormSummary.Value>12345678910</FormSummary.Value>
</FormSummary.Answer>
</FormSummary.Answers>
</FormSummary>
);
const answers = [
{
label: "Fortell din livshistorie",
value: `Det hele startet da jeg ble født en kald vinterdag i desember.
Jeg var en litt spesiell baby, for jeg hadde masse hår på hodet. Mamma
og pappa ble veldig overrasket da de så meg for første gang. De hadde egentlig
forventet at jeg skulle være helt normal og kjedelig. Du vil kanskje ikke høre resten
av historien min, for den er ganske lang og kjedelig...`,
},
{
label: "Fødselsnummer",
value: "12345678910",
},
{
label: "Ting du har i sekken.",
value: [
{
label: "Saks x2",
value: `Jeg liker å ha med meg en saks i sekken min. Det er alltid kjekt å ha en saks. Jeg pakker gjerne med meg to saks. Da har jeg en reserve hvis den første saksen min skulle bli borte. Det er veldig kjekt.`,
},
{
label: "Blyant",
value:
"Det er ikke mulig å skrive uten en blyant. Da blir det bare rot.",
},
{
label: "Kake",
value: `Jeg har alltid en kake i sekken min. Den blir litt most etter en
stund i sekken min men det gjør ingenting. Jeg liker moste kaker. Det er veldig godt.`,
},
{
label: "Kaffekopp",
value: "Dette har jeg ikke i sekken min",
},
],
},
{
label: "Flere ting du har i sekken.",
value: [
{
label: "Saks",
value: `En saks til`,
},
],
},
];
export const RealisticUsage: StoryFn<typeof FormSummary> = () => (
<FormSummary>
<FormSummary.Header>
<FormSummary.Heading level="2">
Diverse informasjon om forskjellige ting og tang. Kjekt å vite.
</FormSummary.Heading>
<FormSummary.EditLink href="#" />
</FormSummary.Header>
<FormSummary.Answers>
{answers.map((answer, index) => (
<FormSummary.Answer key={index}>
<FormSummary.Label>{answer.label}</FormSummary.Label>
<FormSummary.Value>
{Array.isArray(answer.value) ? (
<FormSummary.Answers>
{answer.value.map((subAnswer, subIndex) => (
<FormSummary.Answer key={subIndex}>
<FormSummary.Label>{subAnswer.label}</FormSummary.Label>
<FormSummary.Value>{subAnswer.value}</FormSummary.Value>
</FormSummary.Answer>
))}
</FormSummary.Answers>
) : (
answer.value
)}
</FormSummary.Value>
</FormSummary.Answer>
))}
</FormSummary.Answers>
</FormSummary>
);
export const Empty: StoryFn<typeof FormSummary> = () => (
<VStack gap="8">
<FormSummary>
<FormSummary.Header>
<FormSummary.Heading level="2">Just Header</FormSummary.Heading>
<FormSummary.EditLink href="#" />
</FormSummary.Header>
</FormSummary>
<FormSummary>
<FormSummary.Header>
<FormSummary.Heading level="2">Empty Answers</FormSummary.Heading>
<FormSummary.EditLink href="#" />
</FormSummary.Header>
<FormSummary.Answers>{null}</FormSummary.Answers>
</FormSummary>
<FormSummary>
<FormSummary.Header>
<FormSummary.Heading level="2">Empty Answer</FormSummary.Heading>
<FormSummary.EditLink href="#" />
</FormSummary.Header>
<FormSummary.Answers>
<FormSummary.Answer>{null}</FormSummary.Answer>
</FormSummary.Answers>
</FormSummary>
<FormSummary>
<FormSummary.Header>
<FormSummary.Heading level="2">Empty Label & Value</FormSummary.Heading>
<FormSummary.EditLink href="#" />
</FormSummary.Header>
<FormSummary.Answers>
<FormSummary.Answer>
<FormSummary.Label>{null}</FormSummary.Label>
<FormSummary.Value>{null}</FormSummary.Value>
</FormSummary.Answer>
</FormSummary.Answers>
</FormSummary>
<FormSummary>
<FormSummary.Header>
<FormSummary.Heading level="2">Empty Value</FormSummary.Heading>
<FormSummary.EditLink href="#" />
</FormSummary.Header>
<FormSummary.Answers>
<FormSummary.Answer>
<FormSummary.Label>Adresse</FormSummary.Label>
<FormSummary.Value>{null}</FormSummary.Value>
</FormSummary.Answer>
</FormSummary.Answers>
</FormSummary>
</VStack>
);
export const Chromatic: StoryObj<typeof FormSummary> = {
render: () => (
<div>
<div>
<h2>Default</h2>
<Default>{null}</Default>
</div>
<div>
<h2>Long Texts</h2>
<LongTexts>{null}</LongTexts>
</div>
<div>
<h2>No Link</h2>
<NoLink>{null}</NoLink>
</div>
<div>
<h2>Realistic Usage</h2>
<RealisticUsage>{null}</RealisticUsage>
</div>
<div>
<h2>Empty</h2>
<Empty>{null}</Empty>
</div>
</div>
),
parameters: {
chromatic: {
disable: false,
modes: {
default: {
viewport: {
width: 620,
},
},
sm: {
viewport: {
width: 479,
},
},
},
},
},
};
export const CommaSeparated: StoryFn<typeof FormSummary> = () => (
<FormSummary>
<FormSummary.Header>
<FormSummary.Heading level="2">Personalia</FormSummary.Heading>
<FormSummary.EditLink href="#" />
</FormSummary.Header>
<FormSummary.Answers>
<FormSummary.Answer>
<FormSummary.Label>
Hvilket transportmiddel er du mest kjent for?
</FormSummary.Label>
<FormSummary.Value>
sykkel, tog, bil, buss, sparkesykkel
</FormSummary.Value>
</FormSummary.Answer>
<FormSummary.Answer>
<FormSummary.Label>Favoritt slagord?</FormSummary.Label>
<FormSummary.Value>{`I'll be back.`}</FormSummary.Value>
<FormSummary.Value>Hasta la vista, baby.</FormSummary.Value>
<FormSummary.Value>Say hello to my little friend.</FormSummary.Value>
</FormSummary.Answer>
</FormSummary.Answers>
</FormSummary>
);
@@ -0,0 +1,106 @@
import cl from "clsx";
import React, { HTMLAttributes, forwardRef } from "react";
import FormSummaryAnswer from "./FormSummaryAnswer";
import FormSummaryAnswers from "./FormSummaryAnswers";
import FormSummaryEditLink from "./FormSummaryEditLink";
import FormSummaryHeader from "./FormSummaryHeader";
import FormSummaryHeading from "./FormSummaryHeading";
import FormSummaryLabel from "./FormSummaryLabel";
import FormSummaryValue from "./FormSummaryValue";
interface FormSummaryComponent
extends React.ForwardRefExoticComponent<
FormSummaryProps & React.RefAttributes<HTMLDivElement>
> {
/**
* Must include `<FormSummary.Heading>` and optionally `<FormSummary.EditLink>`.
*/
Header: typeof FormSummaryHeader;
/**
* Typographic Heading to use in the `FormSummary.Header` component.
*/
Heading: typeof FormSummaryHeading;
/**
* Link to edit the answers to use in the `FormSummary.Header` component. Should link to the relevant part of the form.
*/
EditLink: typeof FormSummaryEditLink;
/**
* Wrapper component for the answers.
*/
Answers: typeof FormSummaryAnswers;
/**
* Wrapper component for each answer. To be used in the `FormSummary.Answers` component.
*/
Answer: typeof FormSummaryAnswer;
/**
* Corresponds to the question in the form. To be used in the `FormSummary.Answer` component.
*/
Label: typeof FormSummaryLabel;
/**
* Corresponds to the answer in the form. To be used in the `FormSummary.Answer` component.
*/
Value: typeof FormSummaryValue;
}
export interface FormSummaryProps extends HTMLAttributes<HTMLDivElement> {
/**
* Must include:
*
* - `<FormSummary.Header>`
* - `<FormSummary.Answers>`
*
* @example
* <FormSummary>
* <FormSummary.Header>
* <FormSummary.Heading level="2">HeadingTekst</FormSummary.Heading>
* <FormSummary.EditLink href="#" />
* </FormSummary.Header>
* <FormSummary.Answers>
* <FormSummary.Answer>
* <FormSummary.Label>Navn</FormSummary.Label>
* <FormSummary.Value>Ola Nordmann</FormSummary.Value>
* </FormSummary.Answer>
* </FormSummary.Answers>
* </FormSummary>
*/
children: React.ReactNode;
}
/**
* A summary of a previously answered form.
*
* @see [📝 Documentation](https://aksel.nav.no/komponenter/core/formsummary)
*
* @example
* <FormSummary>
* <FormSummary.Header>
* <FormSummary.Heading level="2">HeadingTekst</FormSummary.Heading>
* <FormSummary.EditLink href="#" />
* </FormSummary.Header>
* <FormSummary.Answers>
* <FormSummary.Answer>
* <FormSummary.Label>Navn</FormSummary.Label>
* <FormSummary.Value>Ola Nordmann</FormSummary.Value>
* </FormSummary.Answer>
* </FormSummary.Answers>
* </FormSummary>
*/
export const FormSummary = forwardRef<HTMLDivElement, FormSummaryProps>(
({ children, className, ...rest }, ref) => {
return (
<div ref={ref} {...rest} className={cl("navds-form-summary", className)}>
{children}
</div>
);
},
) as FormSummaryComponent;
FormSummary.Header = FormSummaryHeader;
FormSummary.Heading = FormSummaryHeading;
FormSummary.EditLink = FormSummaryEditLink;
FormSummary.Answers = FormSummaryAnswers;
FormSummary.Answer = FormSummaryAnswer;
FormSummary.Label = FormSummaryLabel;
FormSummary.Value = FormSummaryValue;
export default FormSummary;
@@ -0,0 +1,27 @@
import cl from "clsx";
import React from "react";
export interface FormSummaryAnswerProps
extends React.HTMLAttributes<HTMLDivElement> {
/**
* Must include:
* - `<FormSummary.Label>`
* - `<FormSummary.Value>`
*/
children: React.ReactNode;
}
export const FormSummaryAnswer = React.forwardRef<
HTMLDivElement,
FormSummaryAnswerProps
>(({ children, className, ...rest }, ref) => (
<div
ref={ref}
{...rest}
className={cl("navds-form-summary__answer", className)}
>
{children}
</div>
));
export default FormSummaryAnswer;
@@ -0,0 +1,25 @@
import cl from "clsx";
import React, { forwardRef } from "react";
export interface FormSummaryAnswersProps
extends React.HTMLAttributes<HTMLDListElement> {
/**
* Must include one or more of `<FormSummary.Answer>`.
*/
children: React.ReactNode;
}
export const FormSummaryAnswers = forwardRef<
HTMLDListElement,
FormSummaryAnswersProps
>(({ children, className, ...rest }: FormSummaryAnswersProps, ref) => (
<dl
ref={ref}
{...rest}
className={cl("navds-form-summary__answers", className)}
>
{children}
</dl>
));
export default FormSummaryAnswers;
@@ -0,0 +1,35 @@
import cl from "clsx";
import React, { forwardRef } from "react";
import { Link } from "../../link";
import { OverridableComponent } from "../../util/types";
// export type FormSummaryEditProps = Partial<LinkProps>;
export interface FormSummaryEditProps
extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
/**
* URL to the relevant part of the form, where the answers can be edited.
*/
href?: string;
/**
* Link text.
*
* [How to write good links](https://aksel.nav.no/god-praksis/artikler/lenker).
* @default "Endre svar"
*/
children?: React.ReactNode;
}
export const FormSummaryEditLink: OverridableComponent<
FormSummaryEditProps,
HTMLAnchorElement
> = forwardRef(({ children = "Endre svar", className, ...rest }, ref) => (
<Link
ref={ref}
{...rest}
className={cl("navds-form-summary__edit", className)}
>
{children}
</Link>
));
export default FormSummaryEditLink;
@@ -0,0 +1,25 @@
import cl from "clsx";
import React, { forwardRef } from "react";
export interface FormSummaryHeaderProps
extends React.HTMLAttributes<HTMLDivElement> {
/**
* Must include `<FormSummary.Heading>` and optionally `<FormSummary.EditLink>`.
*/
children: React.ReactNode;
}
export const FormSummaryHeader = forwardRef<
HTMLDivElement,
FormSummaryHeaderProps
>(({ children, className, ...rest }, ref) => (
<header
ref={ref}
{...rest}
className={cl("navds-form-summary__header", className)}
>
{children}
</header>
));
export default FormSummaryHeader;
@@ -0,0 +1,23 @@
import React, { forwardRef } from "react";
import { Heading, HeadingProps } from "../../typography";
export interface FormSummaryHeadingProps
extends React.HTMLAttributes<HTMLHeadingElement> {
/**
* Heading text.
*/
children: React.ReactNode;
/**
* The heading level.
*/
level: Exclude<HeadingProps["level"], "1">;
}
export const FormSummaryHeading = forwardRef<
HTMLHeadingElement,
FormSummaryHeadingProps
>((props: FormSummaryHeadingProps, ref) => (
<Heading ref={ref} {...props} size="medium" />
));
export default FormSummaryHeading;
@@ -0,0 +1,17 @@
import React, { forwardRef } from "react";
import { Label } from "../../typography";
export interface FormSummaryLabelProps
extends React.HTMLAttributes<HTMLElement> {
children: React.ReactNode;
}
export const FormSummaryLabel = forwardRef<HTMLElement, FormSummaryLabelProps>(
({ children, ...rest }, ref) => (
<Label ref={ref} {...rest} as="dt">
{children}
</Label>
),
);
export default FormSummaryLabel;
@@ -0,0 +1,24 @@
import cl from "clsx";
import React, { forwardRef } from "react";
import { BodyLong } from "../../typography";
export interface FormSummaryValueProps
extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
}
export const FormSummaryValue = forwardRef<
HTMLDivElement,
FormSummaryValueProps
>(({ children, className, ...rest }, ref) => (
<BodyLong
ref={ref}
{...rest}
as="dd"
className={cl("navds-form-summary__value", className)}
>
{children}
</BodyLong>
));
export default FormSummaryValue;
@@ -0,0 +1,30 @@
"use client";
export { default as FormSummary, type FormSummaryProps } from "./FormSummary";
export {
default as FormSummaryAnswer,
type FormSummaryAnswerProps,
} from "./FormSummaryAnswer";
export {
default as FormSummaryAnswers,
type FormSummaryAnswersProps,
} from "./FormSummaryAnswers";
export {
default as FormSummaryEditLink,
type FormSummaryEditProps,
} from "./FormSummaryEditLink";
export {
default as FormSummaryHeader,
type FormSummaryHeaderProps,
} from "./FormSummaryHeader";
export {
default as FormSummaryHeading,
type FormSummaryHeadingProps,
} from "./FormSummaryHeading";
export {
default as FormSummaryLabel,
type FormSummaryLabelProps,
} from "./FormSummaryLabel";
export {
default as FormSummaryValue,
type FormSummaryValueProps,
} from "./FormSummaryValue";
+16 -15
View File
@@ -26,8 +26,8 @@ export { Dropdown, type DropdownProps } from "./dropdown";
export { ExpansionCard, type ExpansionCardProps } from "./expansion-card";
export {
GuidePanel,
type GuidePanelProps,
GuidePanelDefaultIllustration,
type GuidePanelProps,
} from "./guide-panel";
export { HelpText, type HelpTextProps } from "./help-text";
export {
@@ -125,6 +125,21 @@ export {
} from "./form/confirmation-panel";
export { ErrorSummary, type ErrorSummaryProps } from "./form/error-summary";
export { Fieldset, type FieldsetProps } from "./form/fieldset";
export {
UNSAFE_FileUpload,
type FileAccepted,
type FileItem,
type FileMetadata,
type FileObject,
type FileRejected,
type FileRejectedPartitioned,
type FileRejectionReason,
type FileUploadDropzoneProps,
type FileUploadItemProps,
type FileUploadTriggerProps,
type FilesPartitioned,
} from "./form/file-upload";
export { FormSummary, type FormSummaryProps } from "./form/form-summary";
export {
Radio,
RadioGroup,
@@ -136,20 +151,6 @@ export { Select, type SelectProps } from "./form/select";
export { Switch, type SwitchProps } from "./form/switch";
export { Textarea, type TextareaProps } from "./form/textarea";
export { TextField, type TextFieldProps } from "./form/textfield";
export {
UNSAFE_FileUpload,
type FileUploadDropzoneProps,
type FileUploadTriggerProps,
type FileObject,
type FileRejected,
type FileAccepted,
type FileRejectedPartitioned,
type FilesPartitioned,
type FileRejectionReason,
type FileUploadItemProps,
type FileItem,
type FileMetadata,
} from "./form/file-upload";
/**
* @deprecated
@@ -12,6 +12,6 @@ import React from "react";
* <MyComponent />
* </HStack>
*/
export const Spacer = () => <div className="navds-stack__spacer" />;
export const Spacer = () => <span className="navds-stack__spacer" />;
export default Spacer;
@@ -143,7 +143,21 @@ export class DescendantsManager<
};
private registerNode = (node: T | null, options?: DescendantOptions<K>) => {
if (!node || this.descendants.has(node)) return;
if (!node) return;
/**
* While the node is registered, we have to make sure to sync options
* This is useful when ex. a node is disabled after it has been registered
*/
const mapNode = this.descendants.get(node);
if (mapNode) {
this.descendants.set(node, {
index: mapNode.index,
node,
...options,
} as Descendant<T, K>);
return;
}
const keys = Array.from(this.descendants.keys()).concat(node);
const sorted = sortNodes(keys);
@@ -17,11 +17,6 @@ function useDescendants<
K extends Record<string, any> = object,
>() {
const descendants = useRef(new DescendantsManager<T, K>()).current;
useClientLayoutEffect(() => {
return () => {
descendants.destroy();
};
});
return descendants;
}
+4
View File
@@ -1,5 +1,9 @@
# @navikt/ds-tailwind
## 6.6.1
## 6.6.0
## 6.5.0
## 6.4.1
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/ds-tailwind",
"version": "6.5.0",
"version": "6.6.1",
"description": "Tailwind config based on @navikt/ds-tokens",
"author": "Aksel | NAV designsystem team",
"keywords": [
@@ -25,7 +25,7 @@
"directory": "@navikt/core/tailwind"
},
"devDependencies": {
"@navikt/ds-tokens": "^6.5.0",
"@navikt/ds-tokens": "^6.6.1",
"color": "4.2.3",
"lodash": "^4.17.21",
"tailwindcss": "^3.3.3",
+4
View File
@@ -1,5 +1,9 @@
# @navikt/ds-tokens
## 6.6.1
## 6.6.0
## 6.5.0
## 6.4.1
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/ds-tokens",
"version": "6.5.0",
"version": "6.6.1",
"description": "Design-tokens for NAV designsystem",
"author": "Aksel | NAV designsystem team",
"keywords": [
+416 -398
View File
File diff suppressed because it is too large. Load diff
+5 -5
View File
@@ -15,10 +15,10 @@
"playroom"
],
"dependencies": {
"@navikt/aksel-icons": "^6.5.0",
"@navikt/ds-css": "^6.5.0",
"@navikt/ds-react": "^6.5.0",
"@navikt/ds-tailwind": "^6.5.0",
"@navikt/ds-tokens": "^6.5.0"
"@navikt/aksel-icons": "^6.6.1",
"@navikt/ds-css": "^6.6.1",
"@navikt/ds-react": "^6.6.1",
"@navikt/ds-tailwind": "^6.6.1",
"@navikt/ds-tokens": "^6.6.1"
}
}
+20 -10
View File
@@ -18,20 +18,25 @@ const config: StorybookConfig = {
return loadCsf(code, { ...opts, fileName }).parse().indexInputs;
};
const exampleIndexer = async (fileName: string, opts) => {
const customIndexer = async (fileName: string, opts) => {
let code = readFileSync(fileName, "utf-8").toString();
code = code.replace(
/^\s*export default withDsExample\(Example[\s\S]*?;\s*/gm,
"",
);
const isTemplate = fileName.toLowerCase().includes("templates");
const templatesOrExamples = isTemplate ? "Templates" : "Eksempler";
code = code.replace("export const args =", "const args =");
code = code.split(
/\/\/ EXAMPLES DO NOT INCLUDE CONTENT BELOW THIS LINE/,
)[0];
code += `\nexport default { title: "Eksempler/${fileName
.split("pages/eksempler/")[1]
code += `\nexport default { title: "${templatesOrExamples}/${fileName
.split(`pages/${templatesOrExamples.toLowerCase()}/`)[1]
.replace(".tsx", "")}" };\n`;
code += `\nexport const Demo = {\n
render: Example,\n
};
`;
return loadCsf(code, { ...opts, fileName }).parse().indexInputs;
};
@@ -42,8 +47,12 @@ const config: StorybookConfig = {
createIndex: csfIndexer,
},
{
test: /pages\/eksempler\/|(".+")|(.+.tsx)$/,
createIndex: exampleIndexer,
test: /pages\/eksempler\/.+?.tsx$/,
createIndex: customIndexer,
},
{
test: /pages\/templates\/.+?.tsx$/,
createIndex: customIndexer,
},
];
},
@@ -52,6 +61,7 @@ const config: StorybookConfig = {
"../**/*.mdx",
"../**/*.stories.@(ts|tsx)",
"../pages/eksempler/**/*.tsx",
"../pages/templates/**/*.tsx",
],
addons: [
getAbsolutePath("@storybook/addon-essentials"),
@@ -108,7 +108,7 @@ export function ArticleSections({
{utArticles.map((article) => (
<li key={article.slug}>
<GpArticleCard
href={`${article.slug}`.replace("god-praksis", "gp")}
href={article.slug}
innholdstype={article.innholdstype}
undertema={article.undertema}
publishedAt={article.publishedAt}
@@ -2,6 +2,7 @@ import cl from "clsx";
import Link from "next/link";
import { FileFillIcon, TagFillIcon } from "@navikt/aksel-icons";
import { BodyLong, Detail, Heading } from "@navikt/ds-react";
import ErrorBoundary from "@/error-boundary";
import { AnimatedChevron } from "@/web/AnimatedChevron";
type GpArticleCardProps = {
@@ -45,11 +46,14 @@ function GpArticleCard({
<BodyLong className="mt-1 line-clamp-2">{description}</BodyLong>
)}
<div
className={cl("mt-auto flex h-fit justify-between text-deepblue-700", {
"pt-5": description,
})}
className={cl(
"mt-auto flex h-fit items-end justify-between gap-2 text-deepblue-700",
{
"pt-5": description,
},
)}
>
<div className="flex flex-wrap gap-3">
<div className="flex flex-wrap gap-x-3">
{undertema && (
<div className="flex items-center gap-05 text-teal-700">
<TagFillIcon aria-hidden fontSize="1rem" />
@@ -74,4 +78,10 @@ function GpArticleCard({
);
}
export default GpArticleCard;
export default function Component(props: GpArticleCardProps) {
return (
<ErrorBoundary boundaryName="GpArticleCard">
<GpArticleCard {...props} />
</ErrorBoundary>
);
}
@@ -0,0 +1,52 @@
import type { Meta, StoryObj } from "@storybook/react";
import { HGrid } from "@navikt/ds-react";
import { AkselTheme } from "@/sb-util";
import GpFrontpageCard from "./GpFrontpageCard";
const meta = {
title: "Layout/god-praksis/cards/FrontpageCard",
component: GpFrontpageCard,
tags: ["autodocs"],
decorators: [AkselTheme],
} satisfies Meta<typeof GpFrontpageCard>;
export default meta;
type Story = StoryObj<typeof meta>;
const title = {
short: "Design",
medium: "Brukerinnsikt",
long: "Produktutviklingsmodellen",
};
export const CompactSingle: Story = {
args: {
href: "gp/brukeropplevelse",
children: title.long,
},
};
export const CompactMultiple = {
render: (props) => {
return (
<HGrid gap="4" columns={{ xs: 1, md: 2 }}>
<GpFrontpageCard {...props} href="test">
{title.short}
</GpFrontpageCard>
<GpFrontpageCard {...props} href="test">
{title.short}
</GpFrontpageCard>
<GpFrontpageCard {...props} href="test">
{title.medium}
</GpFrontpageCard>
<GpFrontpageCard {...props} href="test">
{title.long}
</GpFrontpageCard>
</HGrid>
);
},
args: {
innholdstype: "Testing",
undertema: "WCAG",
},
};
@@ -0,0 +1,52 @@
import { SanityImageSource } from "@sanity/image-url/lib/types/types";
import Image from "next/legacy/image";
import NextLink from "next/link";
import ErrorBoundary from "@/error-boundary";
import { FallbackPictogram } from "@/layout/god-praksis-page/FallbackPictogram";
import { amplitudeLogNavigation } from "@/logging";
import { urlFor } from "@/sanity/interface";
type GpFrontpageCardProps = {
children: React.ReactNode;
href: string;
image?: SanityImageSource;
};
const GpFrontpageCard = ({ image, children, href }: GpFrontpageCardProps) => {
return (
<li className="flex items-center gap-2 px-2 py-4 sm:gap-4 sm:px-6">
<div className="relative h-8 w-8 shrink-0 sm:h-12 sm:w-12">
{image ? (
<Image
src={urlFor(image).auto("format").url()}
decoding="sync"
layout="fill"
objectFit="contain"
aria-hidden
priority
/>
) : (
<FallbackPictogram />
)}
</div>
<NextLink
href={href}
passHref
onClick={(e) =>
amplitudeLogNavigation("card", e.currentTarget.getAttribute("href"))
}
className="navds-heading--small navds-link navds-heading flex-wrap break-all text-deepblue-700 no-underline hover:underline focus:outline-none"
>
{children}
</NextLink>
</li>
);
};
export default function Component(props: GpFrontpageCardProps) {
return (
<ErrorBoundary boundaryName="GpFrontpageCard">
<GpFrontpageCard {...props} />
</ErrorBoundary>
);
}
@@ -41,3 +41,24 @@ export const Multiple = {
);
},
};
export const Compact = {
render: () => {
return (
<HStack gap="4">
<GpHeroCard href="test" compact>
Brukerinnsikt
</GpHeroCard>
<GpHeroCard href="test" compact>
Universell Utforming
</GpHeroCard>
<GpHeroCard href="test" compact>
Innsiktsarbeid
</GpHeroCard>
<GpHeroCard href="test" compact>
nav.no
</GpHeroCard>
</HStack>
);
},
};
@@ -6,6 +6,7 @@ import { HTMLAttributes, forwardRef } from "react";
import { BodyShort, Heading } from "@navikt/ds-react";
import { FallbackPictogram } from "@/layout/god-praksis-page/FallbackPictogram";
import { urlFor } from "@/sanity/interface";
import ErrorBoundary from "@/web/ErrorBoundary";
type GpHeroCardProps = {
children: React.ReactNode;
@@ -21,13 +22,19 @@ const GpHeroCard = forwardRef<HTMLAnchorElement, GpHeroCardProps>(
<Link
ref={ref}
href={`/${href}`}
className="group flex gap-2 rounded-lg bg-surface-default py-2 pl-2 pr-3 shadow-xsmall outline-none hover:shadow-small focus-visible:shadow-focus aria-[current]:bg-teal-800 aria-[current]:focus-visible:shadow-focus-gap md:gap-3 md:py-3 md:pl-3 md:pr-6"
className={cl(
"group flex gap-2 rounded-lg bg-surface-default shadow-xsmall outline-none hover:shadow-small focus-visible:shadow-focus aria-[current]:bg-teal-800 aria-[current]:focus-visible:shadow-focus-gap md:gap-3",
"py-2 pl-2 pr-3 md:pl-3 md:pr-6",
{
"md:py-2.5": compact,
"md:py-3": !compact,
},
)}
{...rest}
>
<div
className={cl("relative my-auto shrink-0", {
"size-8 md:size-12": !compact,
"size-6": compact,
className={cl("relative my-auto size-8 shrink-0", {
"md:size-12": !compact,
})}
>
{image ? (
@@ -44,7 +51,7 @@ const GpHeroCard = forwardRef<HTMLAnchorElement, GpHeroCardProps>(
)}
</div>
<div className="grid gap-05">
<div className="grid items-center gap-05">
<Heading
size="small"
as="span"
@@ -68,4 +75,10 @@ const GpHeroCard = forwardRef<HTMLAnchorElement, GpHeroCardProps>(
},
);
export default GpHeroCard;
export default function Component(props: GpHeroCardProps) {
return (
<ErrorBoundary boundaryName="GpHeroCard">
<GpHeroCard {...props} />
</ErrorBoundary>
);
}
@@ -36,10 +36,7 @@ export function GpChipRow({ type, entries }: GpChipRowProps) {
};
return (
<div
/* TODO: After release of new GP structure this could be removed since all articles will required proper data, thus always having chips */
className="hidden has-[.chiplist]:grid"
>
<div>
<Label
as="h2"
className={cl("flex items-center gap-1 text-aksel-heading", {
@@ -54,17 +51,19 @@ export function GpChipRow({ type, entries }: GpChipRowProps) {
<div className="relative mt-2">
<ul id={id} className={cl("flex flex-wrap gap-2 p-1", styles.chips)}>
<GpChip
type={type}
disabled={false}
onClick={reset}
pressed={!safeString(query?.[type])}
>
{`Alle (${entries.reduce((acc, [, count]) => acc + count, 0)})`}
</GpChip>
<li>
<GpChip
type={type}
disabled={false}
onClick={reset}
pressed={!safeString(query?.[type])}
>
{`Alle (${entries.reduce((acc, [, count]) => acc + count, 0)})`}
</GpChip>
</li>
{entries.map(([entryName, count]) => {
return (
<li key={entryName} className="chiplist">
<li key={entryName}>
<GpChip
type={type}
disabled={count === 0}
@@ -22,8 +22,9 @@ function Cube({ variant = "light" }: CubeProps) {
viewBox="0 0 390 560"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden
>
<g clipPath="url(#clip0_4792_2769)">
<g>
<path
d="M414.265 -22.7451L296.355 95.1649L414.089 95.1649L531.999 -22.7451L414.265 -22.7451Z"
stroke="#5EEAD4"
@@ -49,11 +50,6 @@ function Cube({ variant = "light" }: CubeProps) {
strokeLinejoin="round"
/>
</g>
<defs>
<clipPath id="clip0_4792_2769">
<rect width="390" height="580" fill="white" />
</clipPath>
</defs>
</svg>
</div>
);
@@ -109,7 +109,7 @@ function TemaHero({ tema, heroNav }: GpTemaHeroProps) {
shadow="medium"
style={inlineStyles}
role="dialog"
aria-labelledby="tema-selector-title"
aria-label="Temanavigasjon"
aria-modal="false"
ref={(el) => {
setDialogHeight(el?.getBoundingClientRect().height || 0);
@@ -12,14 +12,14 @@ export function HeroCardList({ heroNav, setOpen, currentSlug }: HeroListProps) {
<nav aria-label="Temavelger" className="relative z-10 mt-5">
<ul className="flex flex-col flex-wrap gap-2 md:flex-row lg:gap-4">
<li>
<GpHeroCard href="gp" compact>
<GpHeroCard href="god-praksis" compact>
Alle tema
</GpHeroCard>
</li>
{heroNav.map((tema, idx) => (
<li key={tema.slug + idx}>
<GpHeroCard
href={`gp/${tema.slug}`}
href={`god-praksis/${tema.slug}`}
image={
currentSlug === tema.slug ? tema.imageInverted : tema.image
}
@@ -1,103 +0,0 @@
import NextLink from "next/link";
import { BodyShort, Detail, Heading } from "@navikt/ds-react";
import ErrorBoundary from "@/error-boundary";
import { useFormatedDate } from "@/hooks/useFormatedDate";
import { amplitudeLogNavigation } from "@/logging";
import {
AkselGodPraksisDocT,
ResolveContributorsSingleT,
ResolveSlugT,
ResolveTemaT,
} from "@/types";
import { abbrName } from "@/utils";
type ArtikkelCardProps = ResolveContributorsSingleT<
ResolveTemaT<ResolveSlugT<AkselGodPraksisDocT>>
> & {
source?: string;
variant: string;
level?: "2" | "3";
};
const ArtikkelCard = ({
slug,
source,
heading,
ingress,
contributor,
_updatedAt,
publishedAt,
variant = "god-praksis",
tema,
level = "2",
...rest
}: ArtikkelCardProps) => {
const date = useFormatedDate(
(rest as any)?.updateInfo?.lastVerified ?? publishedAt ?? _updatedAt,
);
return (
<div className="group relative rounded-lg bg-surface-default p-3 pb-16 shadow-xsmall focus-within:ring-[3px] focus-within:ring-border-focus hover:shadow-small sm:p-5 sm:pb-16">
<NextLink
href={{
pathname: `/${slug}`,
query: {
...(source ? { tema: source } : {}),
},
}}
passHref
className="after:absolute after:inset-0 after:z-10 after:rounded-lg focus:outline-none"
onClick={(e) =>
amplitudeLogNavigation(
"artikkel-kort",
e.currentTarget.getAttribute("href"),
)
}
>
<Heading
level={level}
size="small"
className="text-deepblue-700 underline group-hover:no-underline"
>
{heading}
</Heading>
</NextLink>
{ingress && <BodyShort className="mt-2">{ingress}</BodyShort>}
{variant === "god-praksis" ? (
<span className="absolute bottom-5 flex gap-2">
{contributor && (
<Detail as="span">{abbrName(contributor.title)}</Detail>
)}
{contributor && (
<Detail as="span" className="text-text-subtle">
—
</Detail>
)}
<Detail as="span" className="text-text-subtle">
{date}
</Detail>
</span>
) : (
<>
{tema?.[0] && (
<Detail
as="span"
uppercase
className="absolute bottom-5 text-text-subtle"
>
{tema[0]}
</Detail>
)}
</>
)}
</div>
);
};
export default function Component(props: ArtikkelCardProps) {
return (
<ErrorBoundary boundaryName="ArtikkelCard">
<ArtikkelCard {...props} />
</ErrorBoundary>
);
}
@@ -1,62 +0,0 @@
import cl from "clsx";
import Image from "next/legacy/image";
import NextLink from "next/link";
import { BodyShort } from "@navikt/ds-react";
import ErrorBoundary from "@/error-boundary";
import { amplitudeLogNavigation } from "@/logging";
import { urlFor } from "@/sanity/interface";
import { AkselTemaT } from "@/types";
type GodPraksisCardProps = {
node: AkselTemaT;
};
const GodPraksisCard = ({ node }: GodPraksisCardProps) => {
if (
!node?.pictogram ||
!node?.slug?.current ||
!node?.title ||
!node?.refCount
) {
return null;
}
return (
<li className="group relative grid rounded-lg bg-surface-default p-6 shadow-xsmall focus-within:ring-[3px] focus-within:ring-border-focus hover:shadow-small">
<div className="relative mb-4 h-16 w-16">
<Image
src={urlFor(node?.pictogram)
.auto("format")
.url()}
decoding="sync"
layout="fill"
objectFit="contain"
aria-hidden
priority
/>
</div>
<NextLink
href={`/god-praksis/${node.slug.current}`}
passHref
onClick={(e) =>
amplitudeLogNavigation("card", e.currentTarget.getAttribute("href"))
}
className="navds-heading--medium navds-heading mb-4 text-deepblue-700 underline after:absolute after:inset-0 after:rounded-lg focus:outline-none group-hover:no-underline"
>
{node.title}
</NextLink>
<BodyShort className={cl("mb-2 lg:mb-6")}>{node.oppsummering}</BodyShort>
<BodyShort size="small" className="text-text-subtle">
{node.refCount === 1 ? "1 ARTIKKEL" : `${node.refCount} ARTIKLER`}
</BodyShort>
</li>
);
};
export default function Component(props: GodPraksisCardProps) {
return (
<ErrorBoundary boundaryName="GodPraksisCard">
<GodPraksisCard {...props} />
</ErrorBoundary>
);
}
@@ -1,51 +0,0 @@
import Image from "next/legacy/image";
import NextLink from "next/link";
import ErrorBoundary from "@/error-boundary";
import { amplitudeLogNavigation } from "@/logging";
import { urlFor } from "@/sanity/interface";
import { AkselTemaT } from "@/types";
type GodPraksisCardSimpleProps = {
node: Partial<AkselTemaT>;
};
const GodPraksisCardSimple = ({ node }: GodPraksisCardSimpleProps) => {
if (!node?.pictogram || !node?.slug?.current || !node?.title) {
return null;
}
return (
<li className="flex items-center gap-2 px-2 py-4 sm:gap-4 sm:px-6">
<div className="relative h-8 w-8 shrink-0 sm:h-12 sm:w-12">
<Image
src={urlFor(node?.pictogram)
.auto("format")
.url()}
decoding="sync"
layout="fill"
objectFit="contain"
aria-hidden
priority
/>
</div>
<NextLink
href={`/god-praksis/${node.slug.current}`}
passHref
onClick={(e) =>
amplitudeLogNavigation("card", e.currentTarget.getAttribute("href"))
}
className="navds-heading--small navds-link navds-heading flex-wrap break-all text-deepblue-700 no-underline hover:underline focus:outline-none"
>
{node.title}
</NextLink>
</li>
);
};
export default function Component(props: GodPraksisCardSimpleProps) {
return (
<ErrorBoundary boundaryName="GodPraksisCardSimple">
<GodPraksisCardSimple {...props} />
</ErrorBoundary>
);
}
@@ -3,6 +3,9 @@ export type NextPageT<T> = {
slug?: string;
preview: boolean;
id: string;
/**
* @deprecated Should be removed since it's not used anymore.
*/
title: string;
};
notFound: boolean;
@@ -13,25 +13,13 @@ export type ResolveContributorsT<T> = Omit<T, "contributors"> & {
contributors: { title: string }[];
};
export type ResolveContributorsSingleT<T> = Omit<T, "contributors"> & {
contributor: { title: string };
};
export type ResolveTemaT<T> = Omit<T, "tema"> & {
tema: string[];
};
export type ResolveRelatedArticlesT<T> = Omit<T, "relevante_artikler"> & {
relevante_artikler?: ResolveContributorsSingleT<
ResolveSlugT<
ResolveTemaT<
| AkselBloggDocT
| AkselGodPraksisDocT
| AkselKomponentDocT
| AkselGrunnleggendeDocT
>
>
>[];
relevante_artikler?: {
heading: string;
ingress: string;
slug: string;
innholdstype: string;
}[];
};
type DocumentT<T> = {
@@ -202,24 +190,6 @@ export interface AkselGodPraksisLandingPageDocT
seo?: ArticleT["seo"];
}
export interface AkselTemaT extends DocumentT<"aksel_tema"> {
title: string;
oppsummering?: string;
beskrivelse?: any;
refCount?: number;
slug: { current: string };
ansvarlig?: { _ref: string };
seksjoner: ArrayObjectT<{
title: string;
beskrivelse: any;
sider: ResolveContributorsSingleT<
ResolveTemaT<ResolveSlugT<AkselGodPraksisDocT>>
>[];
}>[];
pictogram: any;
seo?: ArticleT["seo"];
}
export interface AccordionT {
list: ArrayObjectT<{
title: string;
@@ -229,7 +229,7 @@ function SanitizedBlock({
id={value?._key}
level="2"
size="large"
className="mb-4 mt-12 max-w-text scroll-mt-20 text-deepblue-800 first-of-type:mt-0 focus:outline-none"
className="mb-4 mt-12 max-w-text scroll-mt-20 text-deepblue-800 first-of-type:mt-0 focus:outline-none dark:text-text-on-inverted"
>
{children}
</Heading>
@@ -237,7 +237,7 @@ function SanitizedBlock({
case "h3":
return (
<Heading
className="mt-8 max-w-text scroll-mt-20 text-deepblue-800 focus:outline-none"
className="mt-8 max-w-text scroll-mt-20 text-deepblue-800 focus:outline-none dark:text-text-on-inverted"
spacing
level="3"
size="medium"
@@ -250,7 +250,7 @@ function SanitizedBlock({
case "h4":
return (
<Heading
className="mt-6 max-w-text text-deepblue-800"
className="mt-6 max-w-text text-deepblue-800 dark:text-text-on-inverted"
spacing
level="4"
size="small"
@@ -264,7 +264,7 @@ function SanitizedBlock({
return (
<BodyLong
spacing
className="last:mb-0 group-[.aksel-block-ingress]/ingress:text-xl"
className="last:mb-0 group-[.aksel-block-ingress]/ingress:text-xl dark:text-text-on-inverted"
>
{children}
</BodyLong>
+1 -2
View File
@@ -6,7 +6,6 @@
"/produktbloggen",
"/komponenter/core/alert",
"/god-praksis/innholdsarbeid",
"/god-praksis/brukerinnsikt",
"/god-praksis/universell-utforming",
"/god-praksis/navno",
"/god-praksis/artikler/innholdsstrategi-for-nav-no"
]
@@ -1,17 +0,0 @@
import { at, defineMigration, patch, unset } from "sanity/migrate";
export default defineMigration({
title: "editor-id-to-mail",
documentTypes: ["editor"],
async *migrate(documents) {
for await (const document of documents()) {
yield patch(document._id, [
at("anon_navn", unset()),
at("user_id", unset()),
at("roller", unset()),
at("anonym", unset()),
]);
}
},
});
+4 -4
View File
@@ -41,7 +41,7 @@
"@sanity/image-url": "1.0.2",
"@sanity/locale-nb-no": "^1.1.0",
"@sanity/table": "1.1.2",
"@sanity/vision": "3.36.3",
"@sanity/vision": "3.37.2",
"@slack/web-api": "^7.0.2",
"boring-avatars": "1.10.1",
"clsx": "^2.1.0",
@@ -63,7 +63,7 @@
"react-responsive-masonry": "^2.1.7",
"react-rx": "^2.1.3",
"remark-gfm": "^4.0.0",
"sanity": "3.36.3",
"sanity": "3.37.2",
"sanity-plugin-asset-source-unsplash": "^1.1.2",
"sanity-plugin-media": "^2.2.5",
"sanity-plugin-utils": "^1.6.4",
@@ -80,8 +80,8 @@
"@next/bundle-analyzer": "14.1.1-canary.57",
"@next/eslint-plugin-next": "14.1.1-canary.57",
"@playwright/test": "1.35.1",
"@sanity/block-tools": "3.36.3",
"@sanity/schema": "3.36.3",
"@sanity/block-tools": "3.37.2",
"@sanity/schema": "3.37.2",
"@types/react": "^18.0.0",
"@types/react-dom": "^18.0.0",
"autoprefixer": "^10.4.0",
+4 -8
View File
@@ -52,21 +52,19 @@ export default async function preview(
}
// Check if the article with the given `slug` exists
const { article, godpraksis, gp } = await previewClient.fetch(
const { article, godpraksis } = await previewClient.fetch(
`{
"article": *[slug.current == $slug][0].slug.current,
"godpraksis": *[slug.current == $godPraksis && _type == "aksel_tema"][0].slug.current,
"gp": *[slug.current == $gp && _type == "gp.tema"][0].slug.current
"godpraksis": *[slug.current == $godpraksis && _type == "gp.tema"][0].slug.current
}`,
{
slug,
godPraksis: slug.replace("god-praksis/", ""),
gp: slug.replace("gp/", ""),
godpraksis: slug.replace("god-praksis/", ""),
},
);
// If the slug doesn't exist prevent preview mode from being enabled
if (!article && !godpraksis && !gp) {
if (!article && !godpraksis) {
return res.status(401).json({ message: "Invalid slug" });
}
@@ -74,8 +72,6 @@ export default async function preview(
return redirectToPreview(res, `/${article}`);
} else if (godpraksis) {
return redirectToPreview(res, `/god-praksis/${godpraksis}`);
} else if (gp) {
return redirectToPreview(res, `/gp/${gp}`);
}
// Redirect to the path from the fetched article
@@ -36,6 +36,11 @@ const client = new WebClient(process.env.SLACK_BOT_TOKEN);
export default authProtectedApi(sendSlackbotFeedback);
type MembersArray = NonNullable<UsersListResponse["members"]>;
type Member = MembersArray[0] & {
id: NonNullable<MembersArray[0]["id"]>;
};
async function sendSlackbotFeedback(
request: NextApiRequest,
response: NextApiResponse,
@@ -70,7 +75,8 @@ async function sendSlackbotFeedback(
"id": _id,
"title": heading,
"editors": contributors[]->email,
"slug": slug.current
"slug": slug.current,
"contacts": undertema[]->tema->contacts[]->email
}`,
{
id: validation.data.body.document_id,
@@ -102,7 +108,27 @@ async function sendSlackbotFeedback(
const slackProfileForEditors = document.editors
.filter(Boolean)
.map((email: string) => findUserByEmail(email, slackMembers.members))
.filter(Boolean) as Exclude<UsersListResponse["members"], undefined>;
.filter(Boolean)
.filter((x) => !!x.id) as Member[];
/**
* We use contacts found on tema connected to article and find their matching slack profiles
*/
const slackProfileForTemaContacts =
(document.contacts
?.filter(Boolean)
.map((email: string) => findUserByEmail(email, slackMembers.members))
.filter(Boolean)
.filter((x) => !!x.id) as Member[]) ?? [];
/**
* We want to make sure we avoid sending the message to the same user multiple times
*/
const ids = [...slackProfileForEditors, ...slackProfileForTemaContacts].map(
(member) => member.id,
);
const recieverSlackIdList = [...new Set(ids)];
let postMessageError = false;
@@ -119,21 +145,19 @@ async function sendSlackbotFeedback(
title: document.title,
},
feedback: validation.data.body.feedback,
recievers: slackProfileForEditors
.map((x) => x.id)
.filter(Boolean) as string[],
recievers: recieverSlackIdList,
sender: {
email: user.email,
slackId: senderSlackUser?.id,
},
}),
...(slackProfileForEditors.length === 0
...(recieverSlackIdList.length === 0
? [
{
type: "section",
text: {
type: "mrkdwn",
text: ":exclamation: Denne artikkelen har ingen redaktører. @here",
text: ":exclamation: Denne artikkelen har ingen redaktører eller temakontater. @here",
},
} satisfies SectionBlock,
]
@@ -147,13 +171,10 @@ async function sendSlackbotFeedback(
);
});
for (const editor of slackProfileForEditors) {
if (!editor.id) {
continue;
}
for (const slackId of recieverSlackIdList) {
await client.chat
.postMessage({
channel: editor.id,
channel: slackId,
text: `Tilbakemelding: ${validation.data.body.feedback}`,
blocks: slackBlock({
article: {
@@ -162,9 +183,7 @@ async function sendSlackbotFeedback(
title: document.title,
},
feedback: validation.data.body.feedback,
recievers: slackProfileForEditors
.map((x) => x.id)
.filter(Boolean) as string[],
recievers: recieverSlackIdList,
sender: {
email: user.email,
slackId: senderSlackUser?.id,
@@ -281,7 +300,7 @@ function slackBlock({ feedback, article, recievers, sender }: SlackBlockT) {
},
{
type: "text",
text: "\n\nMedvirkende (disse fikk tilbakemeldingen):\n",
text: "\n\nDisse fikk tilbakemeldingen (medvirkende og temakontakter):\n",
style: {
bold: true,
},
@@ -14,6 +14,8 @@ Alle linjer som inneholder `examples/withDsExample` + alt under `// EXAMPLES DO
Demo-komponent må ha navn `Example` for at CodeSandbox-knapp skal fungere riktig.
`withDsExample` tar imot parametere, e.g. `{variant: 'static'}` for å sette bredde på komponent til "page width", eller `{variant: 'full'}` for full bredde.
### Args
For å håndtere sortering, kan man sette en index i `args` som da brukes for å bedre sortere eksemplene.
@@ -0,0 +1,33 @@
import { FormSummary } from "@navikt/ds-react";
import { withDsExample } from "@/web/examples/withDsExample";
const Example = () => {
return (
<FormSummary>
<FormSummary.Header>
<FormSummary.Heading level="2">
Personal information
</FormSummary.Heading>
<FormSummary.EditLink href="#">Edit</FormSummary.EditLink>
</FormSummary.Header>
<FormSummary.Answers>
<FormSummary.Answer>
<FormSummary.Label>Name</FormSummary.Label>
<FormSummary.Value>John Doe</FormSummary.Value>
</FormSummary.Answer>
</FormSummary.Answers>
</FormSummary>
);
};
// EXAMPLES DO NOT INCLUDE CONTENT BELOW THIS LINE
export default withDsExample(Example, { variant: "static" });
/* Storybook story */
export const Demo = {
render: Example,
};
export const args = {
index: 2,
};
@@ -0,0 +1,79 @@
import { FormSummary, Link } from "@navikt/ds-react";
import { withDsExample } from "@/web/examples/withDsExample";
const Example = () => {
return (
<FormSummary>
<FormSummary.Header>
<FormSummary.Heading level="2">Personalia</FormSummary.Heading>
<FormSummary.EditLink href="#" />
</FormSummary.Header>
<FormSummary.Answers>
<FormSummary.Answer>
<FormSummary.Label>Navn</FormSummary.Label>
<FormSummary.Value>Ola Nordmann</FormSummary.Value>
</FormSummary.Answer>
<FormSummary.Answer>
<FormSummary.Label>Adresse</FormSummary.Label>
<FormSummary.Value>
Gate 123
<br />
1234 Sted
</FormSummary.Value>
</FormSummary.Answer>
<FormSummary.Answer>
<FormSummary.Label>Fødselsnummer</FormSummary.Label>
<FormSummary.Value>12345678910</FormSummary.Value>
</FormSummary.Answer>
<FormSummary.Answer>
<FormSummary.Label>Barn nr. 1</FormSummary.Label>
<FormSummary.Value>
<FormSummary.Answers>
<FormSummary.Answer>
<FormSummary.Label>Navn</FormSummary.Label>
<FormSummary.Value>Kari Nordmann</FormSummary.Value>
</FormSummary.Answer>
<FormSummary.Answer>
<FormSummary.Label>Kjønn</FormSummary.Label>
<FormSummary.Value>Jente</FormSummary.Value>
</FormSummary.Answer>
<FormSummary.Answer>
<FormSummary.Label>Alder</FormSummary.Label>
<FormSummary.Value>6</FormSummary.Value>
</FormSummary.Answer>
</FormSummary.Answers>
</FormSummary.Value>
</FormSummary.Answer>
<FormSummary.Answer>
<FormSummary.Label>Kontaktinformasjon</FormSummary.Label>
<FormSummary.Value>
<Link>Fyll ut kontaktinformasjon</Link>
</FormSummary.Value>
</FormSummary.Answer>
<FormSummary.Answer>
<FormSummary.Label>Hvordan vil du bli varslet?</FormSummary.Label>
<FormSummary.Value>E-post</FormSummary.Value>
<FormSummary.Value>SMS</FormSummary.Value>
</FormSummary.Answer>
</FormSummary.Answers>
</FormSummary>
);
};
// EXAMPLES DO NOT INCLUDE CONTENT BELOW THIS LINE
export default withDsExample(Example, { variant: "static" });
/* Storybook story */
export const Demo = {
render: Example,
};
export const args = {
index: 0,
};
@@ -0,0 +1,68 @@
import { FormSummary } from "@navikt/ds-react";
import { withDsExample } from "@/web/examples/withDsExample";
const Example = () => {
return (
<FormSummary>
<FormSummary.Header>
<FormSummary.Heading level="2">Personalia</FormSummary.Heading>
<FormSummary.EditLink href="#" />
</FormSummary.Header>
<FormSummary.Answers>
<FormSummary.Answer>
<FormSummary.Label>Barn nr. 1</FormSummary.Label>
<FormSummary.Value>
<FormSummary.Answers>
<FormSummary.Answer>
<FormSummary.Label>Navn</FormSummary.Label>
<FormSummary.Value>Kari Nordmann</FormSummary.Value>
</FormSummary.Answer>
<FormSummary.Answer>
<FormSummary.Label>Kjønn</FormSummary.Label>
<FormSummary.Value>Jente</FormSummary.Value>
</FormSummary.Answer>
<FormSummary.Answer>
<FormSummary.Label>Alder</FormSummary.Label>
<FormSummary.Value>6</FormSummary.Value>
</FormSummary.Answer>
</FormSummary.Answers>
</FormSummary.Value>
</FormSummary.Answer>
<FormSummary.Answer>
<FormSummary.Label>Barn nr. 2</FormSummary.Label>
<FormSummary.Value>
<FormSummary.Answers>
<FormSummary.Answer>
<FormSummary.Label>Navn</FormSummary.Label>
<FormSummary.Value>Per Nordmann</FormSummary.Value>
</FormSummary.Answer>
<FormSummary.Answer>
<FormSummary.Label>Kjønn</FormSummary.Label>
<FormSummary.Value>Gutt</FormSummary.Value>
</FormSummary.Answer>
<FormSummary.Answer>
<FormSummary.Label>Alder</FormSummary.Label>
<FormSummary.Value>7</FormSummary.Value>
</FormSummary.Answer>
</FormSummary.Answers>
</FormSummary.Value>
</FormSummary.Answer>
</FormSummary.Answers>
</FormSummary>
);
};
// EXAMPLES DO NOT INCLUDE CONTENT BELOW THIS LINE
export default withDsExample(Example, { variant: "static" });
/* Storybook story */
export const Demo = {
render: Example,
};
export const args = {
index: 1,
desc: "Man kan gruppere svar ved å neste FormSummary.Answers innenfor FormSummary.Answer.",
};
@@ -35,5 +35,5 @@ export const Demo = {
export const args = {
index: 3,
desc: "Full lar Bleed bryte ut av parent og dekke hele skjermbredden. Dette kan være nyttig for 'Heroes' eller lignende elementer som ønsker å bryte ut av parent-layout",
desc: "I noen tilfeller må du også sette 'overflow-x: hidden;' på body for å unngå horisontal scrollbar. Full lar Bleed bryte ut av parent og dekke hele skjermbredden. Dette kan være nyttig for 'Heroes' eller lignende elementer som ønsker å bryte ut av parent-layout",
};
+111 -203
View File
@@ -1,243 +1,151 @@
import cl from "clsx";
import Image from "next/legacy/image";
import { GetStaticPaths } from "next/types";
import { Suspense, lazy } from "react";
import { Detail, Heading, Label } from "@navikt/ds-react";
import ArtikkelCard from "@/cms/cards/ArtikkelCard";
import { groq } from "next-sanity";
import { GetServerSideProps } from "next/types";
import { useMemo } from "react";
import { Box, Page, VStack } from "@navikt/ds-react";
import Footer from "@/layout/footer/Footer";
import { ArticleSections } from "@/layout/god-praksis-page/ArticleSections";
import { GpChipNavigation } from "@/layout/god-praksis-page/chipnavigation/GpChipNavigation";
import TemaHero from "@/layout/god-praksis-page/hero/tema-hero/TemaHero";
import {
GpSlugQueryResponse,
ParsedGPArticle,
} from "@/layout/god-praksis-page/interface";
import Header from "@/layout/header/Header";
import { SanityBlockContent } from "@/sanity-block";
import { getClient } from "@/sanity/client.server";
import { getAkselTema, urlFor } from "@/sanity/interface";
import { contributorsSingle, destructureBlocks } from "@/sanity/queries";
import { AkselTemaT, NextPageT } from "@/types";
import { abbrName } from "@/utils";
import { AkselCubeStatic } from "@/web/aksel-cube/AkselCube";
import { NextPageT } from "@/types";
import { dateStr } from "@/utils";
import { SEO } from "@/web/seo/SEO";
import NotFotfund from "../404";
type PageProps = NextPageT<{
tema: Omit<AkselTemaT, "ansvarlig"> & {
ansvarlig?: { title: string; roller: string[] };
};
}>;
export const query = `{
"tema": *[_type == "aksel_tema" && slug.current == $slug] | order(_updatedAt desc)[0]{
const sanityQuery = groq`
{
"articles": *[_type == "aksel_artikkel" && defined(undertema) && $slug in undertema[]->tema->slug.current] | order(publishedAt desc) {
_id,
heading,
publishedAt,
"description": ingress,
"undertema": undertema[]->{title, "temaTitle": tema->title},
"innholdstype": innholdstype->title,
"slug": slug.current,
},
"tema": *[_type == "gp.tema" && slug.current == $slug][0]{
...,
"ansvarlig": ansvarlig->{title, roller},
seksjoner[]{
...,
beskrivelse[]{
...,
${destructureBlocks}
},
sider[]->{
_id,
heading,
_createdAt,
_updatedAt,
publishedAt,
updateInfo,
"slug": slug.current,
"tema": tema[]->title,
ingress,
"contributor": ${contributorsSingle}
}
},
"pictogram": pictogram.asset-> {
url,
altText,
"slug": slug.current,
"undertema": *[_type == "gp.tema.undertema" && tema->slug.current == $slug]{
title,
description
},
"image": pictogram,
},
"heroNav": *[_type == "gp.tema" && count(*[_type=="aksel_artikkel"
&& (^._id in undertema[]->tema._ref)]) > 0] | order(lower(title)){
title,
"slug": slug.current,
"image": pictogram,
"imageInverted": pictogramInverted,
}
}`;
}
`;
export const getStaticPaths: GetStaticPaths = async () => {
return {
paths: await getAkselTema().then((paths) =>
paths.map(({ path }) => ({
params: {
slug: path,
},
})),
),
fallback: "blocking",
};
};
type PageProps = NextPageT<GpSlugQueryResponse>;
export const getStaticProps = async ({
params: { slug },
preview = false,
}: {
params: { slug: string };
preview?: boolean;
}): Promise<PageProps> => {
const { tema } = await getClient().fetch(query, {
slug,
});
export const getServerSideProps: GetServerSideProps = async (
ctx,
): Promise<PageProps> => {
const slug = ctx.params?.slug as string;
const preview = !!ctx.preview;
const { heroNav, tema, articles } =
await getClient().fetch<GpSlugQueryResponse>(sanityQuery, {
slug,
});
return {
props: {
tema,
heroNav,
// Avoids having to format date clientside
articles: await Promise.all(
articles.map(async (a) => ({
...a,
publishedAt: await dateStr(a.publishedAt),
})),
),
slug,
preview,
id: tema?._id ?? null,
id: tema?._id ?? "",
title: tema?.title ?? "",
},
notFound: !tema && !preview,
revalidate: 60,
notFound: !tema || articles.length === 0,
};
};
const Page = ({ tema: page }: PageProps["props"]) => {
if (!page || !page.seksjoner || page.seksjoner.length === 0) {
return <NotFotfund />;
}
const GpPage = (props: PageProps["props"]) => {
const articles = useMemo(
() =>
props.articles
.map((article) => {
const undertema = article.undertema.find(
(ut) => ut?.temaTitle === props.tema.title,
)?.title;
const hasAnsvarlig = !!page?.ansvarlig?.title;
const hasPages = !!page.seksjoner.find((x) => !!x.sider);
return {
...article,
undertema: undertema ?? "",
} satisfies ParsedGPArticle;
})
.filter((article) => !!article.undertema)
/* Makes sure sections always appear in the same order */
.sort((a, b) => a.undertema.localeCompare(b.undertema)),
[props.articles, props.tema.title],
);
return (
<>
<SEO
title={page?.title}
description={page?.seo?.meta}
image={page?.seo?.image}
title={props.tema?.title ?? ""}
description={props.tema?.seo?.meta ?? props.tema.description}
image={props.tema?.seo?.image}
/>
<div className="bg-surface-subtle">
<Page
footer={<Footer />}
footerPosition="belowFold"
className="bg-surface-subtle"
>
<Header variant="subtle" />
<main
<Box
paddingBlock="10"
as="main"
tabIndex={-1}
id="hovedinnhold"
className="relative overflow-hidden focus:outline-none"
className="animate-popUpPage focus:outline-none"
key={props.tema?.title}
>
<AkselCubeStatic className="text-deepblue-300 opacity-5 " />
<div className=" pt-20 text-center">
<div className="dynamic-wrapper px-4 pb-6 text-center">
<Image
src={urlFor(page.pictogram.url).auto("format").url()}
decoding="sync"
width="72"
height="72"
layout="fixed"
priority
aria-hidden
alt={page.pictogram?.altText}
/>
<Heading
level="1"
size="xlarge"
className="mt-8 hidden md:block md:text-5xl"
>
{page.title}
</Heading>
<Heading level="1" size="large" className="mt-8 block md:hidden">
{page.title}
</Heading>
<Page.Block width="xl" gutters>
<VStack gap="10">
<TemaHero tema={props.tema} heroNav={props.heroNav} />
<Box paddingInline={{ xs: "4", lg: "10" }}>
<GpChipNavigation articles={articles} />
<div className="mt-4 flex flex-col items-center justify-between gap-8">
<SanityBlockContent
blocks={page.beskrivelse}
className="override-text-700 max-w-prose xl:mb-8"
isIngress
/>
<div
className={cl(
"max-w relative z-10 mb-2 h-fit sm:w-96 xl:mt-[10px]",
{ invisible: !hasAnsvarlig },
)}
aria-hidden={!hasAnsvarlig}
>
<Detail as="div" className="mb-2" uppercase>
Ansvarlig for tema
</Detail>
<div className="grid">
<div>
<Label as="div" className="text-xlarge">
{page?.ansvarlig?.title
? abbrName(page?.ansvarlig?.title)
: ""}
</Label>
{page?.ansvarlig?.roller &&
page.ansvarlig.roller.length > 0 ? (
<div className="mt-[2px] text-medium">
{page?.ansvarlig?.roller.join(", ")}
</div>
) : (
""
)}
</div>
</div>
</div>
</div>
</div>
</div>
<div className="relative px-4 pb-24 pt-8 md:pt-16 xl:pt-8 ">
{hasPages ? (
<div className="dynamic-wrapper grid gap-20">
{page.seksjoner.map((seksjon) =>
seksjon?.sider ? (
<div key={seksjon._key}>
<Heading level="2" size="medium">
{seksjon.title}
</Heading>
{seksjon.beskrivelse && (
<div className="max-w-prose">
<SanityBlockContent blocks={seksjon.beskrivelse} />
</div>
)}
<div className="card-grid-3-1 mt-6">
{seksjon?.sider?.map((x) => (
<ArtikkelCard
{...x}
level="3"
source={page?.slug?.current}
key={x._id}
variant="god-praksis"
/>
))}
</div>
</div>
) : null,
)}
</div>
) : (
<div className="dynamic-wrapper mb-20">
<Heading level="2" size="medium">
Fant ingen artikler her enda...
</Heading>
</div>
)}
</div>
</main>
<Footer />
</div>
<VStack gap="12" className="mt-10">
<ArticleSections
articles={articles}
undertemaList={props.tema.undertema}
/>
</VStack>
</Box>
</VStack>
</Page.Block>
</Box>
</Page>
</>
);
};
const WithPreview = lazy(() => import("@/preview"));
/**
* To avoid infinite loop rendering caused by `count` in query, we avoid showing preview for this page.
*/
const Wrapper = (props: any) => {
if (props?.preview) {
return (
<Suspense fallback={<Page {...props} />}>
<WithPreview
comp={Page}
query={query}
props={props}
params={{
slug: props?.slug,
}}
/>
</Suspense>
);
}
return <Page {...props} />;
return <GpPage {...props} />;
};
export default Wrapper;
@@ -1,46 +1,50 @@
import NextLink from "next/link";
import { SanityImageSource } from "@sanity/image-url/lib/types/types";
import { differenceInMonths } from "date-fns";
import { GetServerSideProps } from "next/types";
import { Suspense, lazy } from "react";
import { ChevronRightIcon } from "@navikt/aksel-icons";
import { BodyLong, BodyShort, Detail, Heading, Label } from "@navikt/ds-react";
import { UserStateT } from "@/auth/auth.types";
import { getAuthUserState } from "@/auth/getUserState";
import ArtikkelCard from "@/cms/cards/ArtikkelCard";
import Footer from "@/layout/footer/Footer";
import GpArticleCard from "@/layout/god-praksis-page/cards/GpArticleCard";
import { GpTemaLink } from "@/layout/god-praksis-page/chipnavigation/GpTemaLink";
import Header from "@/layout/header/Header";
import { SanityBlockContent } from "@/sanity-block";
import { getClient } from "@/sanity/client.server";
import {
contributorsAll,
contributorsSingle,
destructureBlocks,
} from "@/sanity/queries";
import { contributorsAll, destructureBlocks } from "@/sanity/queries";
import {
AkselGodPraksisDocT,
NextPageT,
ResolveContributorsT,
ResolveRelatedArticlesT,
ResolveSlugT,
ResolveTemaT,
TableOfContentsT,
} from "@/types";
import { abbrName, dateStr, generateTableOfContents } from "@/utils";
import { Feedback } from "@/web/Feedback/Feedback";
import OutdatedAlert from "@/web/OutdatedAlert";
import { SEO } from "@/web/seo/SEO";
import TableOfContents from "@/web/toc/TableOfContents";
import NotFotfund from "../../404";
import NotFound from "../../404";
type PageProps = NextPageT<{
page: ResolveContributorsT<
ResolveTemaT<ResolveSlugT<ResolveRelatedArticlesT<AkselGodPraksisDocT>>>
>;
ResolveSlugT<ResolveRelatedArticlesT<AkselGodPraksisDocT>>
> & {
innholdstype: string;
undertema: {
title: string;
tema: { slug: string; title: string; image: SanityImageSource };
}[];
};
publishDate: string;
verifiedDate: string;
outdated: boolean;
toc: TableOfContentsT;
userState: UserStateT;
}>;
export const query = `{
const query = `{
"page": *[slug.current == $slug] | order(_updatedAt desc)[0]
{
...,
@@ -49,19 +53,21 @@ export const query = `{
...,
${destructureBlocks}
},
tema[]->{title, slug, seo},
"innholdstype": innholdstype->title,
"undertema": undertema[]->{
title,
"tema": tema->{
title,
"slug": slug.current,
"image": seo.image
}
},
${contributorsAll},
relevante_artikler[]->{
_id,
heading,
_createdAt,
_updatedAt,
publishedAt,
updateInfo,
"slug": slug.current,
"tema": tema[]->tag,
ingress,
"contributor": ${contributorsSingle},
"slug": slug.current,
"innholdstype": innholdstype->title,
}
}
}`;
@@ -79,6 +85,9 @@ export const getServerSideProps: GetServerSideProps = async (
slug: `god-praksis/artikler/${slug}`,
});
const verifiedDate =
page?.updateInfo?.lastVerified ?? page?.publishedAt ?? page?._updatedAt;
return {
props: {
page,
@@ -86,9 +95,8 @@ export const getServerSideProps: GetServerSideProps = async (
preview: isPreview,
id: page?._id ?? "",
title: page?.heading ?? "",
verifiedDate: await dateStr(
page?.updateInfo?.lastVerified ?? page?.publishedAt ?? page?._updatedAt,
),
verifiedDate: await dateStr(verifiedDate),
outdated: differenceInMonths(new Date(), new Date(verifiedDate)) >= 12,
publishDate: await dateStr(page?.publishedAt ?? page?._updatedAt),
toc: generateTableOfContents({
content: page?.content,
@@ -104,11 +112,12 @@ const Page = ({
page: data,
publishDate,
verifiedDate,
outdated,
toc,
userState,
}: PageProps["props"]) => {
if (!data) {
return <NotFotfund />;
return <NotFound />;
}
if (!data.content || !data.heading) {
@@ -122,8 +131,6 @@ const Page = ({
const authors = (data?.contributors as any)?.map((x) => x?.title) ?? [];
const hasTema = "tema" in data && data.tema && data?.tema.length > 0;
const aside = data?.relevante_artikler &&
data.relevante_artikler.length > 0 && (
<aside
@@ -143,26 +150,28 @@ const Page = ({
: `Relevante artikler`}
</Heading>
<div className="card-grid-3-1 mt-6 px-4">
{data.relevante_artikler.map((x: any) =>
x && x?._id ? (
<ArtikkelCard level="3" {...x} key={x._id} />
) : null,
)}
{data.relevante_artikler.map((x) => (
<GpArticleCard
key={x.heading}
href={x.slug}
description={x.ingress}
innholdstype={x.innholdstype}
>
{x.heading}
</GpArticleCard>
))}
</div>
</div>
</div>
</aside>
);
const filteredTema =
hasTema && data?.tema?.filter((x: any) => x?.title && x?.slug);
return (
<>
<SEO
title={data?.heading}
description={data?.seo?.meta ?? data?.ingress}
image={data?.seo?.image ?? (data?.tema?.[0] as any)?.seo?.image}
image={data?.seo?.image ?? data.undertema?.[0]?.tema?.image}
publishDate={publishDate}
canonical={`/${data.slug}`}
/>
@@ -176,10 +185,18 @@ const Page = ({
<div className="mx-auto max-w-aksel px-4 sm:w-[90%]">
<article className="pb-16 pt-12 md:pb-32">
<div className="mx-auto mb-16 max-w-prose lg:ml-0">
{data.innholdstype && (
<BodyShort
weight="semibold"
className="uppercase text-violet-600"
>
{data.innholdstype}
</BodyShort>
)}
<Heading
level="1"
size="large"
className="text-wrap-balance mt-4 text-deepblue-800 md:text-5xl"
className="text-wrap-balance mt-1 text-deepblue-800 md:text-5xl"
>
{data.heading}
</Heading>
@@ -192,38 +209,26 @@ const Page = ({
</BodyLong>
)}
<div className="mt-6 inline-flex flex-wrap items-center gap-2 text-base">
<Detail uppercase as="span">
{verifiedDate}
<div className="mt-5 grid">
<Detail as="time" textColor="subtle">
{`Oppdatert ${verifiedDate}`}
</Detail>
<Detail as="time" textColor="subtle">
{`Publisert ${publishDate}`}
</Detail>
{authors?.length > 0 && (
<>
<span className="h-2 w-2 rotate-45 rounded-[1px] bg-deepblue-700 opacity-25" />
<BodyShort
size="small"
as="div"
className="flex flex-wrap gap-1"
>
<address className="not-italic">{authors?.[0]}</address>
</BodyShort>
</>
)}
</div>
{hasTema && filteredTema && (
<div className="mt-8 flex flex-wrap gap-2">
{filteredTema.map(({ title, slug }: any) => (
<span key={title}>
<BodyShort
href={`/god-praksis/${slug.current}`}
key={title}
size="small"
as={NextLink}
className="flex min-h-8 items-center justify-center gap-[2px] rounded-full bg-surface-neutral-subtle pl-4 pr-1 text-deepblue-800 ring-1 ring-inset ring-border-subtle hover:bg-surface-neutral-subtle-hover focus:outline-none focus-visible:shadow-focus"
>
{title}
<ChevronRightIcon aria-hidden fontSize="1.25rem" />
</BodyShort>
</span>
{data.undertema && data.undertema.length > 0 && (
<div className="mt-5 flex flex-wrap gap-2">
{data.undertema.map(({ title, tema }) => (
<GpTemaLink
key={title}
href={`/god-praksis/${
tema.slug
}?undertema=${encodeURIComponent(title)}`}
>
{tema.title}
</GpTemaLink>
))}
</div>
)}
@@ -231,11 +236,12 @@ const Page = ({
<div className="relative mx-auto mt-4 max-w-prose lg:ml-0 lg:grid lg:max-w-none lg:grid-flow-row-dense lg:grid-cols-3 lg:items-start lg:gap-x-12">
<TableOfContents toc={toc} variant="subtle" />
<div className="max-w-prose lg:col-span-2 lg:col-start-1">
{outdated && <OutdatedAlert />}
<SanityBlockContent blocks={data?.content ?? []} />
<div className="mt-12">
{authors?.length > 0 && (
<Label className="mb-2 text-deepblue-700" as="p">
Bidragsytere
Medvirkende
</Label>
)}
{authors?.length > 0 && (
@@ -251,9 +257,6 @@ const Page = ({
))}
</BodyShort>
)}
<BodyShort as="span" className="text-text-subtle">
Publisert: {publishDate}
</BodyShort>
{userState && <Feedback userState={userState} />}
</div>
</div>
+187 -102
View File
@@ -1,149 +1,234 @@
import { SanityImageSource } from "@sanity/image-url/lib/types/types";
import cl from "clsx";
import { groq } from "next-sanity";
import NextLink from "next/link";
import { GetStaticProps } from "next/types";
import { Suspense, lazy } from "react";
import { SparklesIcon } from "@navikt/aksel-icons";
import { Heading } from "@navikt/ds-react";
import ArtikkelCard from "@/cms/cards/ArtikkelCard";
import GodPraksisCard from "@/cms/cards/GodPraksisCard";
import {
BodyLong,
Box,
Heading,
Link,
Page,
Stack,
VStack,
} from "@navikt/ds-react";
import Footer from "@/layout/footer/Footer";
import { GpCardGrid } from "@/layout/god-praksis-page/ArticleSections";
import GpArticleCard from "@/layout/god-praksis-page/cards/GpArticleCard";
import GpHeroCard from "@/layout/god-praksis-page/cards/GpHeroCard";
import IntroHero from "@/layout/god-praksis-page/hero/intro-hero/IntroHero";
import Header from "@/layout/header/Header";
import { amplitudeLogNavigation } from "@/logging";
import { SanityBlockContent } from "@/sanity-block";
import { getClient } from "@/sanity/client.server";
import { destructureBlocks } from "@/sanity/queries";
import {
AkselGodPraksisDocT,
AkselGodPraksisLandingPageDocT,
AkselTemaT,
NextPageT,
ResolveContributorsT,
ResolveSlugT,
ResolveTemaT,
} from "@/types";
import { AkselCubeStatic } from "@/web/aksel-cube/AkselCube";
import { AkselGodPraksisLandingPageDocT, NextPageT } from "@/types";
import { AnimatedChevron } from "@/web/AnimatedChevron";
import { SEO } from "@/web/seo/SEO";
type PageProps = NextPageT<{
type GpTemaList = {
page: AkselGodPraksisLandingPageDocT;
temaer: AkselTemaT[];
resent: ResolveTemaT<
ResolveContributorsT<ResolveSlugT<AkselGodPraksisDocT>>
>[];
}>;
tema: {
title: string;
description: string;
slug: string;
refCount: number;
pictogram: SanityImageSource;
articles: {
heading: string;
slug: string;
undertema: { title: string; temaTitle: string }[];
innholdstype: string;
}[];
}[];
};
export const query = `*[_type == "godpraksis_landingsside"][0]{
"page": {
type PageProps = NextPageT<GpTemaList>;
export const query = groq`
{
"page": *[_type == "godpraksis_landingsside"][0]{
...,
intro[]{
...,
${destructureBlocks}
}
},
"temaer": *[_type == "aksel_tema" && defined(seksjoner[].sider[])]{
...,
"refCount": count(*[_type == "aksel_artikkel" && !(_id in path("drafts.**")) && references(^._id)])
},
"resent": *[_type == "aksel_artikkel" && defined(publishedAt)] | order(publishedAt desc)[0...9]{
_id,
heading,
_createdAt,
_updatedAt,
publishedAt,
"tema": *[_type == "gp.tema"] | order(lower(title)){
title,
description,
pictogram,
"slug": slug.current,
"tema": tema[]->title,
ingress,
"refCount": count(*[_type=="aksel_artikkel"
&& (^._id in undertema[]->tema._ref)]),
"articles": *[_type=="aksel_artikkel"
&& (^._id in undertema[]->tema._ref)] {
heading,
"slug": slug.current,
"undertema": undertema[]->{title, "temaTitle": tema->title},
"innholdstype": innholdstype->title,
"views": *[_type == "article_views" && article_ref._ref == ^._id][0].views_month
} | order(coalesce(views, -1) desc)[0...4]{
heading,
slug,
undertema,
innholdstype
},
}
}`;
}
`;
export const getStaticProps: GetStaticProps = async ({
preview = false,
}): Promise<PageProps> => {
const { temaer, page, resent } = await getClient().fetch(query);
const { tema, page } = await getClient().fetch<GpTemaList>(query);
return {
props: {
page,
temaer,
resent,
slug: "/god-praksis",
tema,
preview,
title: "Forside God praksis",
id: page?._id ?? "",
id: "godpraksis_landingsside_id1",
title: "God praksis forside",
},
notFound: !temaer,
notFound: false,
revalidate: 60,
};
};
const Page = ({ temaer, page, resent }: PageProps["props"]) => {
const filteredTemas = temaer.filter((x) => x.refCount && x.refCount > 0);
const GpPage = (props: PageProps["props"]) => {
return (
<>
<SEO
title="God praksis"
description={page?.seo?.meta}
image={page?.seo?.image}
description={props.page?.seo?.meta}
image={props.page?.seo?.image}
/>
<div className="relative overflow-hidden bg-surface-subtle">
<Header variant="transparent" />
<main tabIndex={-1} id="hovedinnhold" className=" focus:outline-none">
<div className="mx-auto mb-40 grid w-full max-w-screen-2xl px-4 pt-20 sm:px-6">
<Heading
level="1"
size="xlarge"
className="mb-4 text-5xl text-deepblue-800"
>
God praksis
</Heading>
{page.intro && <SanityBlockContent isIngress blocks={page.intro} />}
<AkselCubeStatic className="text-deepblue-300 opacity-5 " />
<div>
<ul className="card-grid-3-1 mt-20 ">
{filteredTemas.map((t) => (
<GodPraksisCard key={t._id} node={t} />
))}
</ul>
</div>
<div className="mt-24">
<Heading
level="2"
size="medium"
className="flex items-center gap-2 text-deepblue-800"
>
<SparklesIcon aria-hidden className="shrink-0" /> Nyeste
artikler
</Heading>
<div className="card-grid-3-1 my-6">
{resent.map((art: any) => (
<ArtikkelCard
level="3"
variant="tema"
{...art}
key={art._id}
<Page
footer={<Footer />}
footerPosition="belowFold"
className="bg-surface-subtle"
>
<Header variant="subtle" />
<Box
paddingBlock="10"
as="main"
tabIndex={-1}
id="hovedinnhold"
className="animate-popUpPage focus:outline-none"
>
<Page.Block width="xl" gutters>
<VStack gap="10">
<IntroHero title="God praksis">
{props.page.intro && (
<SanityBlockContent
isIngress
blocks={props.page.intro}
className="mt-4"
/>
))}
</div>
</div>
</div>
</main>
<Footer />
</div>
)}
<nav aria-label="Temavelger">
<Stack
className="mt-6"
gap={{ xs: "4", md: "6" }}
wrap
direction={{ xs: "column", md: "row" }}
as="ul"
>
{props.tema
.filter((x) => x.refCount > 0)
.map((tema) => (
<li key={tema.slug}>
<GpHeroCard
articleCount={tema.refCount}
href={`god-praksis/${tema.slug}`}
image={tema.pictogram}
>
{tema.title}
</GpHeroCard>
</li>
))}
</Stack>
</nav>
</IntroHero>
<Box paddingInline={{ xs: "4", lg: "10" }}>
<VStack gap="12">
{props.tema
.filter((x) => x.refCount > 0)
.map((tema) => {
return (
<section
key={tema.slug}
aria-label={`Tema ${tema.title}`}
>
<Heading
level="2"
size="medium"
className={cl("text-aksel-heading", {
"mb-4": !tema.description,
"mb-2": tema.description,
})}
>
{tema.title}
</Heading>
{tema.description && (
<BodyLong className="mb-6">
{tema.description}
</BodyLong>
)}
<GpCardGrid>
{tema.articles.map((article) => (
<li key={article.slug}>
<GpArticleCard
href={article.slug}
innholdstype={article.innholdstype}
undertema={
article.undertema.find(
(ut) => ut?.temaTitle === tema.title,
)?.title
}
>
{article.heading}
</GpArticleCard>
</li>
))}
</GpCardGrid>
<Link
href={`/god-praksis/${tema.slug}`}
as={NextLink}
className="group mt-4 w-fit text-deepblue-700"
onClick={(e) =>
amplitudeLogNavigation(
"gp-se-alle-link",
e.currentTarget.getAttribute("href"),
)
}
>
<h3 className="flex items-center">
{`Alt fra ${tema.title} `}
<AnimatedChevron scale="inline" />
</h3>
</Link>
</section>
);
})}
</VStack>
</Box>
</VStack>
</Page.Block>
</Box>
</Page>
</>
);
};
const WithPreview = lazy(() => import("@/preview"));
/**
* To avoid infinite loop rendering caused by `count` in query, we avoid showing preview for this page.
*/
const Wrapper = (props: any) => {
if (props?.preview) {
return (
<Suspense fallback={<Page {...props} />}>
<WithPreview comp={Page} query={query} props={props} />
</Suspense>
);
}
return <Page {...props} />;
return <GpPage {...props} />;
};
export default Wrapper;
-170
View File
@@ -1,170 +0,0 @@
import { groq } from "next-sanity";
import { GetServerSideProps } from "next/types";
import { Suspense, lazy, useEffect, useMemo } from "react";
import { Box, Page, VStack } from "@navikt/ds-react";
import Footer from "@/layout/footer/Footer";
import { ArticleSections } from "@/layout/god-praksis-page/ArticleSections";
import { GpChipNavigation } from "@/layout/god-praksis-page/chipnavigation/GpChipNavigation";
import TemaHero from "@/layout/god-praksis-page/hero/tema-hero/TemaHero";
import {
GpSlugQueryResponse,
ParsedGPArticle,
} from "@/layout/god-praksis-page/interface";
import Header from "@/layout/header/Header";
import { getClient } from "@/sanity/client.server";
import { NextPageT } from "@/types";
import { dateStr } from "@/utils";
import { SEO } from "@/web/seo/SEO";
const sanityQuery = groq`
{
"articles": *[_type == "aksel_artikkel" && defined(undertema) && $slug in undertema[]->tema->slug.current] | order(publishedAt desc) {
_id,
heading,
publishedAt,
"description": ingress,
"undertema": undertema[]->{title, "temaTitle": tema->title},
"innholdstype": innholdstype->title,
"slug": slug.current,
},
"tema": *[_type == "gp.tema" && slug.current == $slug][0]{
...,
"slug": slug.current,
"undertema": *[_type == "gp.tema.undertema" && tema->slug.current == $slug]{
title,
description
},
"image": pictogram,
},
"heroNav": *[_type == "gp.tema" && count(*[_type=="aksel_artikkel"
&& (^._id in undertema[]->tema._ref)]) > 0] | order(lower(title)){
title,
"slug": slug.current,
"image": pictogram,
"imageInverted": pictogramInverted,
}
}
`;
type PageProps = NextPageT<GpSlugQueryResponse>;
export const getServerSideProps: GetServerSideProps = async (
ctx,
): Promise<PageProps> => {
const slug = ctx.params?.slug as string;
const preview = !!ctx.preview;
const { heroNav, tema, articles } =
await getClient().fetch<GpSlugQueryResponse>(sanityQuery, {
slug,
});
return {
props: {
tema,
heroNav,
// Avoids having to format date clientside
articles: await Promise.all(
articles.map(async (a) => ({
...a,
publishedAt: await dateStr(a.publishedAt),
})),
),
slug,
preview,
id: tema._id ?? "",
title: tema.title ?? "",
},
notFound: !tema || articles.length === 0,
};
};
const GpPage = (props: PageProps["props"]) => {
useEffect(() => {
window.location.host === "aksel.nav.no" &&
window.location.replace(`http://aksel.nav.no/404`);
}, []);
const articles = useMemo(
() =>
props.articles
.map((article) => {
const undertema = article.undertema.find(
(ut) => ut?.temaTitle === props.tema.title,
)?.title;
return {
...article,
undertema: undertema ?? "",
} satisfies ParsedGPArticle;
})
.filter((article) => !!article.undertema)
/* Makes sure sections always appear in the same order */
.sort((a, b) => a.undertema.localeCompare(b.undertema)),
[props.articles, props.tema.title],
);
return (
<>
<SEO
title={props.tema?.title ?? ""}
description={props.tema?.seo?.meta ?? props.tema.description}
image={props.tema?.seo?.image}
/>
<Page
footer={<Footer />}
footerPosition="belowFold"
className="bg-surface-subtle"
>
<Header variant="subtle" />
<Box
paddingBlock="10"
as="main"
tabIndex={-1}
id="hovedinnhold"
className="animate-popUpPage focus:outline-none"
key={props.tema?.title}
>
<Page.Block width="xl" gutters>
<VStack gap="10">
<TemaHero tema={props.tema} heroNav={props.heroNav} />
<Box paddingInline={{ xs: "4", lg: "10" }}>
<GpChipNavigation articles={articles} />
<VStack gap="12" className="mt-10">
<ArticleSections
articles={articles}
undertemaList={props.tema.undertema}
/>
</VStack>
</Box>
</VStack>
</Page.Block>
</Box>
</Page>
</>
);
};
const WithPreview = lazy(() => import("@/preview"));
const Wrapper = (props: any) => {
if (props?.preview) {
return (
<Suspense fallback={<GpPage {...props} />}>
<WithPreview
comp={GpPage}
query={sanityQuery}
props={props}
params={{
slug: props?.slug,
}}
/>
</Suspense>
);
}
return <GpPage {...props} />;
};
export default Wrapper;
@@ -1,316 +0,0 @@
import { differenceInMonths } from "date-fns";
import { GetServerSideProps } from "next/types";
import { Suspense, lazy } from "react";
import { BodyLong, BodyShort, Detail, Heading, Label } from "@navikt/ds-react";
import { UserStateT } from "@/auth/auth.types";
import { getAuthUserState } from "@/auth/getUserState";
import ArtikkelCard from "@/cms/cards/ArtikkelCard";
import Footer from "@/layout/footer/Footer";
import { GpTemaLink } from "@/layout/god-praksis-page/chipnavigation/GpTemaLink";
import Header from "@/layout/header/Header";
import { SanityBlockContent } from "@/sanity-block";
import { getClient } from "@/sanity/client.server";
import {
contributorsAll,
contributorsSingle,
destructureBlocks,
} from "@/sanity/queries";
import {
AkselGodPraksisDocT,
NextPageT,
ResolveContributorsT,
ResolveRelatedArticlesT,
ResolveSlugT,
TableOfContentsT,
} from "@/types";
import { abbrName, dateStr, generateTableOfContents } from "@/utils";
import { Feedback } from "@/web/Feedback/Feedback";
import OutdatedAlert from "@/web/OutdatedAlert";
import { SEO } from "@/web/seo/SEO";
import TableOfContents from "@/web/toc/TableOfContents";
import NotFound from "../../404";
type PageProps = NextPageT<{
page: ResolveContributorsT<
ResolveSlugT<ResolveRelatedArticlesT<AkselGodPraksisDocT>>
> & {
innholdstype: string;
undertema: { title: string; tema: { slug: string; title: string } }[];
};
publishDate: string;
verifiedDate: string;
outdated: boolean;
toc: TableOfContentsT;
userState: UserStateT;
}>;
const query = `{
"page": *[slug.current == $slug] | order(_updatedAt desc)[0]
{
...,
"slug": slug.current,
content[]{
...,
${destructureBlocks}
},
"innholdstype": innholdstype->title,
"undertema": undertema[]->{
title,
"tema": tema->{
title,
"slug": slug.current,
}
},
${contributorsAll},
relevante_artikler[]->{
_id,
heading,
_createdAt,
_updatedAt,
publishedAt,
updateInfo,
"slug": slug.current,
"tema": tema[]->tag,
ingress,
"contributor": ${contributorsSingle},
}
}
}`;
export const getServerSideProps: GetServerSideProps = async (
context,
): Promise<PageProps> => {
const userState = await getAuthUserState(context.req.headers);
const isPreview = context.preview ?? false;
const slug = context.params?.slug as string;
const { page } = await getClient().fetch(query, {
slug: `god-praksis/artikler/${slug}`,
});
const verifiedDate =
page?.updateInfo?.lastVerified ?? page?.publishedAt ?? page?._updatedAt;
return {
props: {
page,
slug,
preview: isPreview,
id: page?._id ?? "",
title: page?.heading ?? "",
verifiedDate: await dateStr(verifiedDate),
outdated: differenceInMonths(new Date(), new Date(verifiedDate)) >= 12,
publishDate: await dateStr(page?.publishedAt ?? page?._updatedAt),
toc: generateTableOfContents({
content: page?.content,
type: "aksel_artikkel",
}),
userState,
},
notFound: !page && !isPreview,
};
};
const Page = ({
page: data,
publishDate,
verifiedDate,
outdated,
toc,
userState,
}: PageProps["props"]) => {
if (!data) {
return <NotFound />;
}
if (!data.content || !data.heading) {
console.warn(
`Artikkelen har ikke ${
!data.content ? "innhold" : "overskrift"
}, så den kan ikke vises.`,
);
return null;
}
const authors = (data?.contributors as any)?.map((x) => x?.title) ?? [];
const aside = data?.relevante_artikler &&
data.relevante_artikler.length > 0 && (
<aside
className="overflow-x-clip py-8"
aria-labelledby="relevante-artikler-aside"
>
<div className="relativept-12 pb-16">
<div className="dynamic-wrapper">
<Heading
level="2"
size="medium"
className="px-4 text-deepblue-700"
id="relevante-artikler-aside"
>
{data?.relevante_artikler?.length === 1
? `Les også`
: `Relevante artikler`}
</Heading>
<div className="card-grid-3-1 mt-6 px-4">
{data.relevante_artikler.map((x: any) =>
x && x?._id ? (
<ArtikkelCard level="3" {...x} key={x._id} />
) : null,
)}
</div>
</div>
</div>
</aside>
);
return (
<>
<SEO
title={data?.heading}
description={data?.seo?.meta ?? data?.ingress}
image={data?.seo?.image ?? (data?.tema?.[0] as any)?.seo?.image}
publishDate={publishDate}
canonical={`/${data.slug}`}
/>
<Header variant="subtle" />
<main
tabIndex={-1}
id="hovedinnhold"
className="aksel-artikkel group/aksel bg-surface-subtle pt-4 focus:outline-none"
>
<div className="mx-auto max-w-aksel px-4 sm:w-[90%]">
<article className="pb-16 pt-12 md:pb-32">
<div className="mx-auto mb-16 max-w-prose lg:ml-0">
{data.innholdstype && (
<BodyShort
weight="semibold"
className="uppercase text-violet-600"
>
{data.innholdstype}
</BodyShort>
)}
<Heading
level="1"
size="large"
className="text-wrap-balance mt-1 text-deepblue-800 md:text-5xl"
>
{data.heading}
</Heading>
{data?.ingress && (
<BodyLong
size="large"
className="override-text-700 mt-4 text-2xl"
>
{data?.ingress}
</BodyLong>
)}
<div className="mt-6 inline-flex flex-wrap items-center gap-2 text-base">
<Detail uppercase as="span">
{verifiedDate}
</Detail>
{authors?.length > 0 && (
<>
<span className="h-2 w-2 rotate-45 rounded-[1px] bg-deepblue-700 opacity-25" />
<BodyShort
size="small"
as="div"
className="flex flex-wrap gap-1"
>
<address className="not-italic">{authors?.[0]}</address>
</BodyShort>
</>
)}
</div>
{data.undertema && data.undertema.length > 0 && (
<div className="mt-5 flex flex-wrap gap-2">
{data.undertema.map(({ title, tema }) => (
<GpTemaLink
key={title}
href={`/gp/${tema.slug}?undertema=${encodeURIComponent(
title,
)}`}
>
{tema.title}
</GpTemaLink>
))}
</div>
)}
</div>
<div className="relative mx-auto mt-4 max-w-prose lg:ml-0 lg:grid lg:max-w-none lg:grid-flow-row-dense lg:grid-cols-3 lg:items-start lg:gap-x-12">
<TableOfContents toc={toc} variant="subtle" />
<div className="max-w-prose lg:col-span-2 lg:col-start-1">
{outdated && <OutdatedAlert />}
<SanityBlockContent blocks={data?.content ?? []} />
<div className="mt-12">
{authors?.length > 0 && (
<Label className="mb-2 text-deepblue-700" as="p">
Bidragsytere
</Label>
)}
{authors?.length > 0 && (
<BodyShort
as="div"
className="mb-1 flex flex-wrap gap-1 text-text-subtle"
>
{authors.map(abbrName).map((x, y) => (
<address className="not-italic" key={x}>
{x}
{y !== authors.length - 1 && ", "}
</address>
))}
</BodyShort>
)}
<BodyShort as="span" className="text-text-subtle">
Publisert: {publishDate}
</BodyShort>
{userState && <Feedback userState={userState} />}
</div>
</div>
</div>
</article>
</div>
{aside}
</main>
<Footer />
</>
);
};
const WithPreview = lazy(() => import("@/preview"));
const Wrapper = (props: any) => {
if (props?.preview) {
return (
<Suspense fallback={<Page {...props} />}>
<WithPreview
comp={Page}
query={query}
props={props}
params={{
slug: `god-praksis/artikler/${props?.slug}`,
}}
resolvers={[
{
key: "toc",
dataKeys: ["page.content"],
cb: (v) =>
generateTableOfContents({
content: v[0],
type: "aksel_artikkel",
}),
},
]}
/>
</Suspense>
);
}
return <Page {...props} />;
};
export default Wrapper;
-249
View File
@@ -1,249 +0,0 @@
import cl from "clsx";
import { groq } from "next-sanity";
import NextLink from "next/link";
import { GetStaticProps } from "next/types";
import { Suspense, lazy, useEffect } from "react";
import {
BodyLong,
Box,
Heading,
Link,
Page,
Stack,
VStack,
} from "@navikt/ds-react";
import Footer from "@/layout/footer/Footer";
import { GpCardGrid } from "@/layout/god-praksis-page/ArticleSections";
import GpArticleCard from "@/layout/god-praksis-page/cards/GpArticleCard";
import GpHeroCard from "@/layout/god-praksis-page/cards/GpHeroCard";
import IntroHero from "@/layout/god-praksis-page/hero/intro-hero/IntroHero";
import Header from "@/layout/header/Header";
import { amplitudeLogNavigation } from "@/logging";
import { SanityBlockContent } from "@/sanity-block";
import { getClient } from "@/sanity/client.server";
import { destructureBlocks } from "@/sanity/queries";
import { AkselGodPraksisLandingPageDocT, NextPageT } from "@/types";
import { AnimatedChevron } from "@/web/AnimatedChevron";
import { SEO } from "@/web/seo/SEO";
type GpTemaList = {
page: AkselGodPraksisLandingPageDocT;
tema: {
title: string;
description: string;
slug: string;
refCount: number;
pictogram: any;
articles: {
heading: string;
slug: string;
undertema: { title: string; temaTitle: string }[];
innholdstype: string;
}[];
}[];
};
type PageProps = NextPageT<GpTemaList>;
export const query = groq`
{
"page": *[_type == "godpraksis_landingsside"][0]{
...,
intro[]{
...,
${destructureBlocks}
}
},
"tema": *[_type == "gp.tema"] | order(lower(title)){
title,
description,
pictogram,
"slug": slug.current,
"refCount": count(*[_type=="aksel_artikkel"
&& (^._id in undertema[]->tema._ref)]),
"articles": *[_type=="aksel_artikkel"
&& (^._id in undertema[]->tema._ref)] {
heading,
"slug": slug.current,
"undertema": undertema[]->{title, "temaTitle": tema->title},
"innholdstype": innholdstype->title,
"views": *[_type == "article_views" && article_ref._ref == ^._id][0].views_month
} | order(coalesce(views, -1) desc)[0...4]{
heading,
slug,
undertema,
innholdstype
},
}
}
`;
export const getStaticProps: GetStaticProps = async ({
preview = false,
}): Promise<PageProps> => {
const { tema, page } = await getClient().fetch<GpTemaList>(query);
return {
props: {
page,
tema,
preview,
id: "godpraksis_landingsside_id1",
title: "God praksis forside",
},
notFound: false,
revalidate: 60,
};
};
const GpPage = (props: PageProps["props"]) => {
useEffect(() => {
window.location.host === "aksel.nav.no" &&
window.location.replace(`http://aksel.nav.no/404`);
}, []);
return (
<>
<SEO
title="God praksis"
description={props.page?.seo?.meta}
image={props.page?.seo?.image}
/>
<Page
footer={<Footer />}
footerPosition="belowFold"
className="bg-surface-subtle"
>
<Header variant="subtle" />
<Box
paddingBlock="10"
as="main"
tabIndex={-1}
id="hovedinnhold"
className="animate-popUpPage focus:outline-none"
>
<Page.Block width="xl" gutters>
<VStack gap="10">
<IntroHero title="God praksis">
{props.page.intro && (
<SanityBlockContent
isIngress
blocks={props.page.intro}
className="mt-4"
/>
)}
<nav aria-label="Temavelger">
<Stack
className="mt-6"
gap={{ xs: "4", md: "6" }}
wrap
direction={{ xs: "column", md: "row" }}
as="ul"
>
{props.tema
.filter((x) => x.refCount > 0)
.map((tema) => (
<li key={tema.slug}>
<GpHeroCard
articleCount={tema.refCount}
href={`gp/${tema.slug}`}
image={tema.pictogram}
>
{tema.title}
</GpHeroCard>
</li>
))}
</Stack>
</nav>
</IntroHero>
<Box paddingInline={{ xs: "4", lg: "10" }}>
<VStack gap="12">
{props.tema
.filter((x) => x.refCount > 0)
.map((tema) => {
return (
<section
key={tema.slug}
aria-label={`Tema ${tema.title}`}
>
<Heading
level="2"
size="medium"
className={cl("text-aksel-heading", {
"mb-4": !tema.description,
"mb-2": tema.description,
})}
>
{tema.title}
</Heading>
{tema.description && (
<BodyLong className="mb-6">
{tema.description}
</BodyLong>
)}
<GpCardGrid>
{tema.articles.map((article) => (
<li key={article.slug}>
<GpArticleCard
href={`${article.slug}`.replace(
"god-praksis",
"gp",
)}
innholdstype={article.innholdstype}
undertema={
article.undertema.find(
(ut) => ut?.temaTitle === tema.title,
)?.title
}
>
{article.heading}
</GpArticleCard>
</li>
))}
</GpCardGrid>
<Link
href={`/gp/${tema.slug}`}
as={NextLink}
className="group mt-4 w-fit text-deepblue-700"
onClick={(e) =>
amplitudeLogNavigation(
"gp-se-alle-link",
e.currentTarget.getAttribute("href"),
)
}
>
<h3 className="flex items-center">
{`Alt fra ${tema.title} `}
<AnimatedChevron scale="inline" />
</h3>
</Link>
</section>
);
})}
</VStack>
</Box>
</VStack>
</Page.Block>
</Box>
</Page>
</>
);
};
const WithPreview = lazy(() => import("@/preview"));
const Wrapper = (props: any) => {
if (props?.preview) {
return (
<Suspense fallback={<GpPage {...props} />}>
<WithPreview comp={GpPage} query={query} props={props} />
</Suspense>
);
}
return <GpPage {...props} />;
};
export default Wrapper;
+29 -22
View File
@@ -1,4 +1,6 @@
import { SanityImageSource } from "@sanity/image-url/lib/types/types";
import cl from "clsx";
import { groq } from "next-sanity";
import { GetStaticProps } from "next/types";
import { Suspense, lazy, useState } from "react";
import {
@@ -18,22 +20,26 @@ import {
VStack,
useClientLayoutEffect,
} from "@navikt/ds-react";
import GodPraksisCardSimple from "@/cms/cards/GodPraksisCardSimple";
import FrontpageBlock, {
BlocksT,
} from "@/cms/frontpage-blocks/FrontpageBlocks";
import Footer from "@/layout/footer/Footer";
import GpFrontpageCard from "@/layout/god-praksis-page/cards/GpFrontpageCard";
import Header from "@/layout/header/Header";
import { getClient } from "@/sanity/client.server";
import { contributorsAll } from "@/sanity/queries";
import { AkselTemaT, NextPageT } from "@/types";
import { NextPageT } from "@/types";
import { userPrefersReducedMotion } from "@/utils";
import { IntroCards } from "@/web/IntroCards";
import { AkselCubeAnimated } from "@/web/aksel-cube/AkselCube";
import { SEO } from "@/web/seo/SEO";
type PageProps = NextPageT<{
tema: AkselTemaT[];
tema: {
title: string;
slug: { current: string };
pictogram: SanityImageSource;
}[];
page: {
title: string;
god_praksis_intro: string;
@@ -42,11 +48,18 @@ type PageProps = NextPageT<{
blocks?: BlocksT[];
}>;
const query = `*[_type == "aksel_forside"][0]{
/**
* Using `count` with references in groq query causes infinite loop when previewing
*/
const query = groq`*[_type == "aksel_forside"][0]{
"page": {
...,
},
"tema": *[_type == "aksel_tema" && defined(seksjoner[].sider[])],
"tema": select(
$preview == "true" => *[_type == "gp.tema"] | order(lower(title)),
$preview != "true" => *[_type == "gp.tema" && count(*[_type=="aksel_artikkel"
&& (^._id in undertema[]->tema._ref)]) > 0] | order(lower(title))
),
blocks[]{
...,
_type == "nytt_fra_aksel"=>{
@@ -54,7 +67,7 @@ const query = `*[_type == "aksel_forside"][0]{
...,
"content": null,
${contributorsAll},
"tema": tema[]->title,
"tema": undertema[]->tema->title,
},
"curatedResent": {
"bloggposts": *[_type == "aksel_blogg" && !(_id in ^.highlights[]._ref)] | order(_createdAt desc)[0...2]{
@@ -78,7 +91,7 @@ const query = `*[_type == "aksel_forside"][0]{
_updatedAt,
publishedAt,
"slug": slug.current,
"tema": tema[]->title,
"tema": undertema[]->tema->title,
ingress,
seo,
${contributorsAll}
@@ -91,7 +104,7 @@ const query = `*[_type == "aksel_forside"][0]{
_updatedAt,
publishedAt,
"slug": slug.current,
"tema": tema[]->title,
"tema": undertema[]->tema->title,
ingress,
seo,
${contributorsAll}
@@ -130,21 +143,9 @@ export const getStaticProps: GetStaticProps = async ({
preview: "false",
});
const validateTema = tema
.filter(
(t) =>
t?.title &&
t?.slug &&
t?.pictogram &&
t?.seksjoner.some(
(seksjon) => seksjon?.sider.some((side: any) => side?._ref),
),
)
.sort((a, b) => a.title.localeCompare(b.title));
return {
props: {
tema: validateTema,
tema,
page,
blocks,
slug: "/",
@@ -290,7 +291,13 @@ const Forside = ({ page, tema, blocks }: PageProps["props"]) => {
<ul className="grid gap-x-8 md:grid-cols-2 xl:grid-cols-3">
{tema.map((t) => (
<GodPraksisCardSimple key={t._id} node={t} />
<GpFrontpageCard
key={t.title}
href={`/god-praksis/${t.slug.current}`}
image={t.pictogram}
>
{t.title}
</GpFrontpageCard>
))}
</ul>
</VStack>
@@ -77,6 +77,11 @@ function useDekorator() {
// EXAMPLES DO NOT INCLUDE CONTENT BELOW THIS LINE
export default Example;
/* Storybook story */
export const Demo = {
render: Example,
};
export const args = {
index: 1,
title: "Enkel",
@@ -111,6 +111,11 @@ function useDekorator() {
// EXAMPLES DO NOT INCLUDE CONTENT BELOW THIS LINE
export default Example;
/* Storybook story */
export const Demo = {
render: Example,
};
export const args = {
index: 0,
title: "Komplett",
@@ -91,6 +91,11 @@ function useDekorator() {
// EXAMPLES DO NOT INCLUDE CONTENT BELOW THIS LINE
export default Example;
/* Storybook story */
export const Demo = {
render: Example,
};
export const args = {
index: 2,
title: "Med CTA til side",
@@ -92,6 +92,11 @@ function useDekorator() {
// EXAMPLES DO NOT INCLUDE CONTENT BELOW THIS LINE
export default Example;
/* Storybook story */
export const Demo = {
render: Example,
};
export const args = {
index: 3,
title: "Med tilbakemeldingsfunksjon",
@@ -100,6 +100,11 @@ function useDekorator() {
// EXAMPLES DO NOT INCLUDE CONTENT BELOW THIS LINE
export default Example;
/* Storybook story */
export const Demo = {
render: Example,
};
export const args = {
index: 4,
title: "Flerspråk",
@@ -32,6 +32,11 @@ const Example = () => {
// EXAMPLES DO NOT INCLUDE CONTENT BELOW THIS LINE
export default Example;
/* Storybook story */
export const Demo = {
render: Example,
};
export const args = {
index: 5,
title: "Uten dekoratøren",
@@ -108,6 +108,11 @@ function useDekorator() {
// EXAMPLES DO NOT INCLUDE CONTENT BELOW THIS LINE
export default Example;
/* Storybook story */
export const Demo = {
render: Example,
};
export const args = {
index: 1,
title: "Enkel",
@@ -139,6 +139,11 @@ function useDekorator() {
// EXAMPLES DO NOT INCLUDE CONTENT BELOW THIS LINE
export default Example;
/* Storybook story */
export const Demo = {
render: Example,
};
export const args = {
index: 0,
title: "Komplett",
@@ -115,6 +115,11 @@ function useDekorator() {
// EXAMPLES DO NOT INCLUDE CONTENT BELOW THIS LINE
export default Example;
/* Storybook story */
export const Demo = {
render: Example,
};
export const args = {
index: 2,
title: "Med CTA til side",
@@ -117,6 +117,11 @@ function useDekorator() {
// EXAMPLES DO NOT INCLUDE CONTENT BELOW THIS LINE
export default Example;
/* Storybook story */
export const Demo = {
render: Example,
};
export const args = {
index: 4,
title: "Med feil-id",
@@ -130,6 +130,11 @@ function useDekorator() {
// EXAMPLES DO NOT INCLUDE CONTENT BELOW THIS LINE
export default Example;
/* Storybook story */
export const Demo = {
render: Example,
};
export const args = {
index: 5,
title: "Flerspråk",
@@ -93,6 +93,11 @@ function Example() {
// EXAMPLES DO NOT INCLUDE CONTENT BELOW THIS LINE
export default Example;
/* Storybook story */
export const Demo = {
render: Example,
};
export const args = {
index: 6,
title: "Uten Dekoratør",
@@ -12,7 +12,7 @@ export async function sitemapPages(
let client = token ? noCdnClient(token) : getClient();
client = client.config({ perspective: "published" });
const artikler = await getDocuments("all", token);
const temaer = await getAkselTema(token);
const temaer = await getGpTema(token);
const pages = await client.fetch(
`{
@@ -67,24 +67,6 @@ export async function getGpTema(
}));
}
/**
* TODO:
* - Deprecate when getGpTema gets implemented in production
*/
export async function getAkselTema(
token?: string,
): Promise<{ path: string; lastmod: string }[]> {
const client = token ? noCdnClient(token) : getClient();
const tags: { slug: { current: string }; _updatedAt: string }[] =
await client.fetch(
`*[_type == "aksel_tema" && count(*[references(^._id)]) > 0]{slug,_updatedAt}`,
);
return tags.map((x) => ({
path: x?.slug.current,
lastmod: x?._updatedAt,
}));
}
export async function getDocuments(
source: (typeof allArticleDocuments)[number] | "all",
token?: string,
@@ -168,8 +168,6 @@ ${defaultBlock},
export const contributorsAll = `contributors[]->{title}`;
export const contributorsSingle = `contributors[0]->{title}`;
export const sidebarQuery = `"sidebar": *[_type == $type && defined(kategori)] {
heading,
"slug": slug.current,
@@ -1,53 +0,0 @@
// MediaTipInput.tsx
import { SanityDocument, set, useFormValue } from "sanity";
import { useListeningQuery } from "sanity-plugin-utils";
import { Chips, Label, VStack } from "@navikt/ds-react";
export function TaxonomyInput(props) {
const innholdstype = useFormValue(["gp_taxonomy", "innholdstype"]) as {
_ref: string;
} | null;
const { data: innholdstyper } = useListeningQuery<SanityDocument[]>(
`*[_type == $type]`,
{
params: { type: "gp.innholdstype" },
initialValue: [],
},
);
if (!innholdstyper) {
return null;
}
return (
<VStack gap="4">
{innholdstyper?.length > 0 && (
<VStack gap="2">
<Label as="p" size="small">
Innholdstype
</Label>
<Chips>
{innholdstyper.map((type) => (
<Chips.Toggle
selected={innholdstype?._ref === type._id}
onClick={() => {
props.onChange(
set({ _ref: type._id, _type: "reference" }, [
"innholdstype",
]),
);
}}
checkmark={false}
variant="neutral"
key={type._id}
>
{(type?.title as string) ?? ""}
</Chips.Toggle>
))}
</Chips>
</VStack>
)}
</VStack>
);
}
@@ -1,58 +0,0 @@
import { definePlugin } from "sanity";
import { structureTool } from "sanity/structure";
import { publicationFlow } from "../publication-flow";
import innholdsType from "./documents/innholdstype";
import tema from "./documents/tema";
import undertema from "./documents/undertema";
import { defaultDocumentNode, structure } from "./structure";
export const GP_DOCUMENT_NAMES = [tema.name, undertema.name, innholdsType.name];
export const godPraksisTaxonomy = definePlugin(() => {
return {
name: "gp.taxonomy",
schema: {
templates: [
{
id: "gp.tema.undertema.by.tema",
title: "Undertema",
schemaType: "gp.tema.undertema",
parameters: [{ name: "id", type: "string" }],
value: async (params) => {
return {
tema: { _type: "reference", _ref: params.id },
};
},
},
{
id: "gp.artikkel.by.undertema",
title: "God praksis aritkkel med undertema",
schemaType: "gp.artikkel",
parameters: [{ name: "undertema_id", type: "string" }],
value: (params) => ({
undertema: [{ _type: "reference", _ref: params.undertema_id }],
}),
},
{
id: "gp.artikkel.by.innholdstype",
title: "God praksis aritkkel med innholdstype",
schemaType: "gp.artikkel",
parameters: [{ name: "id", type: "string" }],
value: (params) => ({
innholdstype: { _type: "reference", _ref: params.id },
}),
},
],
},
plugins: [
structureTool({
name: "aksel_taxonomy",
title: "Aksel Taxonomy",
structure,
defaultDocumentNode,
}),
/* Needs to be redefined here to avoid duplicate actions for documents */
publicationFlow(),
],
};
});
@@ -1,176 +0,0 @@
import { CurrentUser } from "sanity";
import {
DefaultDocumentNodeResolver,
StructureResolver,
} from "sanity/structure";
import { SANITY_API_VERSION } from "@/sanity/config";
const adminOrDev = (user: CurrentUser | null) =>
user?.roles.find((role) =>
["developer", "administrator"].includes(role.name),
) ?? false;
export const structure: StructureResolver = (S, { currentUser }) => {
if (!adminOrDev(currentUser)) {
return S.list().title("Ingen tilgang");
}
return S.list()
.title("Aksel Manager")
.items([
S.documentTypeListItem("gp.tema").title("Tema"),
S.divider(),
S.listItem({
id: "tema_view",
title: "Undertema",
schemaType: "gp.tema.undertema",
child: () =>
S.documentTypeList("gp.tema")
.child((id) => {
return S.documentTypeList("gp.tema.undertema")
.title("Undertema")
.filter("_type == $type && tema._ref == $id")
.apiVersion(SANITY_API_VERSION)
.params({ type: "gp.tema.undertema", id })
.initialValueTemplates([
S.initialValueTemplateItem("gp.tema.undertema.by.tema", {
id,
}),
]);
})
.initialValueTemplates([]),
}),
S.listItem({
id: "article_undertema_view",
title: "Artikler",
schemaType: "aksel_artikkel",
child: () =>
S.documentTypeList("gp.tema")
.child((id) => {
return S.documentTypeList("gp.tema.undertema")
.title("Undertema")
.filter("_type == $type && tema._ref == $id")
.apiVersion(SANITY_API_VERSION)
.params({ type: "gp.tema.undertema", id })
.child((undertema_id) => {
return S.documentTypeList("aksel_artikkel")
.title("Artikler")
.filter("_type == $type && $id in undertema[]._ref")
.apiVersion(SANITY_API_VERSION)
.params({ type: "aksel_artikkel", id: undertema_id });
});
})
.initialValueTemplates([]),
}),
S.listItem({
id: "article_no_undertema_view",
title: "Uten undertema",
schemaType: "aksel_artikkel",
child: () =>
S.documentTypeList("aksel_artikkel")
.title("Artikler")
.filter("_type == $type && !defined(undertema)")
.apiVersion(SANITY_API_VERSION)
.params({ type: "aksel_artikkel" })
.initialValueTemplates([]),
}),
S.divider(),
S.documentTypeListItem("gp.innholdstype").title("Innholdstype"),
S.listItem({
id: "article_innholdstype_view",
title: "Artikler",
schemaType: "aksel_artikkel",
child: () =>
S.documentTypeList("gp.innholdstype")
.child((id) => {
return S.documentTypeList("aksel_artikkel")
.title("Artikler")
.filter("_type == $type && $id == innholdstype._ref")
.apiVersion(SANITY_API_VERSION)
.params({ type: "aksel_artikkel", id });
})
.initialValueTemplates([]),
}),
S.listItem({
id: "article_no_innholdstype_view",
title: "Uten innholdstype",
schemaType: "aksel_artikkel",
child: () =>
S.documentTypeList("aksel_artikkel")
.title("Artikler")
.filter("_type == $type && !defined(innholdstype)")
.apiVersion(SANITY_API_VERSION)
.params({ type: "aksel_artikkel" })
.initialValueTemplates([]),
}),
/* S.listItem({
id: "artikkel_view",
title: "Artikler (tema)",
schemaType: "gp.artikkel",
child: () =>
S.documentTypeList("gp.tema")
.child((tema_id) =>
S.documentTypeList("gp.tema.undertema")
.title("Undertema")
.filter("_type == $type && tema._ref == $tema_id")
.params({ type: "gp.tema.undertema", tema_id })
.initialValueTemplates([])
.child((undertema_id) =>
S.documentTypeList("gp.artikkel")
.title("Artikler")
.filter(
"_type == $type && $undertema_id in undertema[]._ref"
)
.params({ type: "gp.artikkel", undertema_id })
.initialValueTemplates([
S.initialValueTemplateItem("gp.artikkel.by.undertema", {
undertema_id,
}),
])
)
)
.initialValueTemplates([]),
}),
S.listItem({
id: "artikkel_view_innholdstype",
title: "Artikler (innholdstype)",
schemaType: "gp.artikkel",
child: () =>
S.documentTypeList("gp.innholdstype")
.child((id) =>
S.documentTypeList("gp.artikkel")
.title("Innholdstype")
.filter("_type == $type && innholdstype._ref == $id")
.params({ type: "gp.artikkel", id })
.initialValueTemplates([
S.initialValueTemplateItem("gp.artikkel.by.innholdstype", {
id,
}),
])
)
.initialValueTemplates([]),
}), */
]);
};
// set default document node here — so that if users want concepts
// and schemes elsewhere in desk, they'll get the right views.
export const defaultDocumentNode: DefaultDocumentNodeResolver = (
S,
{ schemaType },
) => {
// Conditionally return a different configuration based on the schema type
switch (schemaType) {
case "gp.tema":
return S.document().views([S.view.form()]);
case "gp.tema.undertema":
return S.document().views([S.view.form()]);
case "gp.innholdstype":
return S.document().views([S.view.form()]);
case "gp.artikkel":
return S.document().views([S.view.form()]);
default:
S.view.form();
}
};
@@ -1,66 +0,0 @@
import { defineArrayMember, defineField } from "sanity";
import { SANITY_API_VERSION } from "@/sanity/config";
import { TaxonomyInput } from "./TaxonomyInput";
export const gpTaxonomyObject = {
title: "God praksis struktur (versjon 2)",
name: "gp_taxonomy",
type: "object",
group: "staging",
components: {
input: TaxonomyInput,
},
fields: [
defineField({
name: "innholdstype",
title: "Innholdstype",
type: "reference",
to: [{ type: "gp.innholdstype" }],
options: {
disableNew: true,
},
/* Add required after update */
/* validation: (Rule) => Rule.required(), */
}),
defineField({
name: "undertema",
title: "Undertema",
type: "array",
/* Add required after update */
/* validation: (Rule) => Rule.required(), */
/* */
of: [
defineArrayMember({
title: "Undertema",
type: "reference",
to: [{ type: "gp.tema.undertema" }],
options: {
disableNew: true,
filter: async ({ getClient, parent }) => {
const undertema = (
parent as {
_key: string;
_ref?: string;
_type: "reference";
}[]
)
.filter((x) => !!x._ref)
.map((x) => x._ref);
const client = getClient({ apiVersion: SANITY_API_VERSION });
const temaIds = await client.fetch("*[_id in $ids].tema._ref", {
ids: undertema,
});
return {
filter: "!(tema._ref in $temaIds)",
params: { temaIds },
};
},
},
}),
],
}),
],
};
@@ -68,15 +68,11 @@ export const publicationFlowConfig = definePlugin<PublicationFlowOptions>(
({ hasQualityControl, hasPublishedAt }) => ({
name: "publication-flow",
document: {
actions: (prev, { schemaType, currentUser }) => {
actions: (prev, { schemaType }) => {
if (hasQualityControl.some((e) => e === schemaType)) {
return getCustomActions(prev);
} else if (hasPublishedAt.some((e) => e === schemaType)) {
return withCustomPublishAction(prev);
} else if (schemaType === "aksel_feedback") {
return currentUser?.roles.find((x) => x.name === "developer")
? prev
: [prev[0]];
}
return prev;
@@ -0,0 +1,95 @@
import { StructureBuilder } from "sanity/structure";
import {
CircleSlashIcon,
EyeIcon,
FileTextIcon,
ImageIcon,
} from "@navikt/aksel-icons";
import { SANITY_API_VERSION } from "@/sanity/config";
export function adminStructure(S: StructureBuilder) {
const adminOrDev = S.context.currentUser?.roles.find((x) =>
["developer", "administrator"].includes(x.name),
);
if (!adminOrDev) {
return S.divider();
}
return S.listItem()
.title("Admin")
.icon(CircleSlashIcon)
.child(
S.list()
.title("Admin")
.items([
S.documentListItem()
.title(`Forside`)
.schemaType(`aksel_forside`)
.icon(ImageIcon)
.id(`aksel_forside_dokument`),
S.listItem()
.title("Standalone-sider")
.child(
S.documentList()
.title("Sider")
.filter(`_type == 'aksel_standalone'`)
.apiVersion(SANITY_API_VERSION),
),
S.listItem()
.title("Redirects")
.child(
S.documentList()
.title("Redirects")
.filter(`_type == 'redirect'`)
.apiVersion(SANITY_API_VERSION),
),
S.listItem()
.title("Eksempler/Templates")
.child(
S.documentList()
.title("Eksempler")
.filter(`_type == 'kode_eksempler_fil'`)
.apiVersion(SANITY_API_VERSION),
),
S.listItem()
.title("Token-grupper Designsystemet")
.child(
S.documentList()
.title("Grupper")
.filter(`_type == 'token_kategori'`)
.apiVersion(SANITY_API_VERSION),
),
S.listItem()
.title("Props Designsystemet")
.child(
S.documentList()
.title("Props")
.filter(`_type == 'ds_props'`)
.apiVersion(SANITY_API_VERSION),
),
S.documentListItem()
.title(`Skrivehjelp`)
.schemaType(`skrivehjelp`)
.icon(FileTextIcon)
.id(`skrivehjelp`),
S.documentListItem()
.title(`Publiseringsflyt`)
.schemaType(`publication_flow`)
.icon(FileTextIcon)
.id(`publication_flow`),
S.listItem()
.title("Artikkelvisninger")
.icon(EyeIcon)
.child(
S.documentList()
.title("Artikkelvisninger")
.filter(`_type == 'article_views'`)
.apiVersion(SANITY_API_VERSION)
.menuItems([
...(S.documentTypeList("article_views").getMenuItems() ?? []),
]),
),
]),
);
}
@@ -1,67 +1,189 @@
import { StructureBuilder } from "sanity/structure";
import { FileXMarkIcon, TagIcon } from "@navikt/aksel-icons";
import { HouseIcon, PencilBoardIcon } from "@navikt/aksel-icons";
import { SANITY_API_VERSION } from "@/sanity/config";
import {
editorIsContributorFilter,
listMyDraftArticles,
listPublishedArticles,
} from "./structure.util";
/* documentStore is in Alpha, so avoid using for now */
export const GodPraksisPanes = async (getClient, S: StructureBuilder) => {
let tema = await getClient({ apiVersion: SANITY_API_VERSION }).fetch(
`*[_type == "aksel_tema"]{title,seksjoner, _id}`,
);
tema = tema
.map((x) => ({
title: x.title,
sider:
x.seksjoner?.reduce((b, n) => [...b, ...(n?.sider ?? [])], []) || [],
_id: x._id,
}))
.filter(
(x) =>
!(
x._id.startsWith("drafts") &&
tema.find((y) => y._id === x._id.replace("drafts.", ""))
),
export function gpStructure(S: StructureBuilder) {
return S.listItem()
.title("God praksis")
.icon(PencilBoardIcon)
.child(
S.list()
.title("God Praksis")
.items([
S.documentListItem()
.title(`Landingsside`)
.icon(HouseIcon)
.schemaType(`godpraksis_landingsside`)
.id(`godpraksis_landingsside_id1`),
S.divider(),
...godPraksisPanes(S),
]),
);
}
const ids = await getClient({ apiVersion: SANITY_API_VERSION }).fetch(
`*[_type == "aksel_artikkel"]{_id, tema}`,
);
function godPraksisPanes(S: StructureBuilder) {
return [
S.listItem()
.title(`Temasider (${tema.length})`)
.icon(TagIcon)
.child(S.documentTypeList("aksel_tema")),
listPublishedArticles(S, "aksel_artikkel"),
listMyDraftArticles(S, "aksel_artikkel"),
S.listItem({
id: "my_gp_outdated",
title: "Mine artikler som trenger oppdatering",
schemaType: "aksel_artikkel",
child: (_, { structureContext }) => {
const mail = structureContext.currentUser?.email;
return S.documentTypeList("aksel_artikkel")
.title("Artikler")
.filter(
`_type == $type && (dateTime(updateInfo.lastVerified + "T00:00:00Z") < dateTime(now()) - 60*60*24*365) && ${editorIsContributorFilter}`,
)
.apiVersion(SANITY_API_VERSION)
.params({ type: "aksel_artikkel", mail })
.initialValueTemplates([])
.defaultOrdering([
{ field: "updateInfo.lastVerified", direction: "asc" },
]);
},
}),
S.divider(),
...tema.map(({ title, _id }) =>
S.listItem()
.title(
`${title} (${
ids.filter(
(x) =>
x?.tema &&
x?.tema?.find?.((y) => y._ref === _id.replace("drafts.", "")),
).length ?? 0
})`,
)
.child(
S.documentList()
.title(`${title}`)
.filter(`_type == 'aksel_artikkel' && $undertema in tema[]._ref`)
.params({ undertema: _id.replace("drafts.", "") })
.apiVersion(SANITY_API_VERSION),
/* .menuItems([...S.documentTypeList("aksel_artikkel").getMenuItems()]) */
),
),
S.documentTypeListItem("gp.tema").title("Tema"),
S.listItem({
id: "tema_view",
title: "Undertema",
schemaType: "gp.tema.undertema",
child: () =>
S.documentTypeList("gp.tema")
.child((id) => {
return S.documentTypeList("gp.tema.undertema")
.title("Undertema")
.filter("_type == $type && tema._ref == $id")
.apiVersion(SANITY_API_VERSION)
.params({ type: "gp.tema.undertema", id })
.initialValueTemplates([
S.initialValueTemplateItem("gp.tema.undertema.by.tema", {
id,
}),
]);
})
.initialValueTemplates([]),
}),
S.documentTypeListItem("gp.innholdstype").title("Innholdstyper"),
S.divider(),
S.listItem()
.title(`Artikler uten tema (${ids.filter((x) => !x?.tema).length ?? 0})`)
.icon(FileXMarkIcon)
.child(
S.documentList()
.title(`Artikler uten tema`)
.filter(`_type == 'aksel_artikkel' && !defined(tema)`)
.apiVersion(SANITY_API_VERSION),
/* .menuItems([...S.documentTypeList("aksel_artikkel").getMenuItems()]) */
),
S.listItem({
id: "article_tema_complete_view",
title: "Tema -> Artikler",
schemaType: "aksel_artikkel",
child: () =>
S.documentTypeList("gp.tema")
.child((id) => {
return S.documentTypeList("aksel_artikkel")
.title("Artikler")
.filter("_type == $type && $id in undertema[]->tema._ref")
.apiVersion(SANITY_API_VERSION)
.params({ type: "aksel_artikkel", id });
})
.initialValueTemplates([]),
}),
S.listItem({
id: "article_undertema_view",
title: "Tema -> Undertema -> Artikler",
schemaType: "aksel_artikkel",
child: () =>
S.documentTypeList("gp.tema")
.child((id) => {
return S.documentTypeList("gp.tema.undertema")
.title("Undertema")
.filter("_type == $type && tema._ref == $id")
.apiVersion(SANITY_API_VERSION)
.params({ type: "gp.tema.undertema", id })
.child((undertema_id) => {
return S.documentTypeList("aksel_artikkel")
.title("Artikler")
.filter("_type == $type && $id in undertema[]._ref")
.apiVersion(SANITY_API_VERSION)
.params({ type: "aksel_artikkel", id: undertema_id })
.initialValueTemplates([
S.initialValueTemplateItem("gp.artikkel.by.undertema", {
undertema_id,
}),
]);
})
.initialValueTemplates([]);
})
.initialValueTemplates([]),
}),
S.listItem({
id: "article_innholdstype_view",
title: "Innholdstype -> Artikler",
schemaType: "aksel_artikkel",
child: () =>
S.documentTypeList("gp.innholdstype")
.child((id) => {
return S.documentTypeList("aksel_artikkel")
.title("Artikler")
.filter("_type == $type && $id == innholdstype._ref")
.apiVersion(SANITY_API_VERSION)
.params({ type: "aksel_artikkel", id })
.initialValueTemplates([
S.initialValueTemplateItem("gp.artikkel.by.innholdstype", {
id,
}),
]);
})
.initialValueTemplates([]),
}),
S.divider(),
S.listItem({
id: "article_no_undertema_view",
title: "Uten undertema",
schemaType: "aksel_artikkel",
child: () =>
S.documentTypeList("aksel_artikkel")
.title("Artikler")
.filter("_type == $type && !defined(undertema)")
.apiVersion(SANITY_API_VERSION)
.params({ type: "aksel_artikkel" })
.initialValueTemplates([]),
}),
S.listItem({
id: "article_no_innholdstype_view",
title: "Uten innholdstype",
schemaType: "aksel_artikkel",
child: () =>
S.documentTypeList("aksel_artikkel")
.title("Artikler")
.filter("_type == $type && !defined(innholdstype)")
.apiVersion(SANITY_API_VERSION)
.params({ type: "aksel_artikkel" })
.initialValueTemplates([]),
}),
S.listItem({
id: "article_gp_outdated_tema",
title: "Trenger oppdatering",
schemaType: "aksel_artikkel",
child: () =>
S.documentTypeList("gp.tema")
.child((id) => {
return S.documentTypeList("aksel_artikkel")
.title("Artikler")
.filter(
`_type == $type && $id in undertema[]->tema._ref && (dateTime(updateInfo.lastVerified + "T00:00:00Z") < dateTime(now()) - 60*60*24*365)`,
)
.apiVersion(SANITY_API_VERSION)
.params({ type: "aksel_artikkel", id })
.initialValueTemplates([])
.defaultOrdering([
{ field: "updateInfo.lastVerified", direction: "asc" },
]);
})
.initialValueTemplates([]),
}),
];
};
}
@@ -0,0 +1,28 @@
import { StructureBuilder } from "sanity/structure";
import { HouseIcon, TokenIcon } from "@navikt/aksel-icons";
import { grunnleggendeKategorier } from "@/sanity/config";
import { Panes } from "./panes";
import { listDraftArticles, listOutdatedArticles } from "./structure.util";
export function grunnleggendeStructure(S: StructureBuilder) {
return S.listItem()
.title("Grunnleggende")
.icon(TokenIcon)
.child(
S.list()
.title("Grunnleggende")
.items([
S.documentListItem()
.title(`Landingsside`)
.icon(HouseIcon)
.schemaType(`grunnleggende_landingsside`)
.id(`grunnleggende_landingsside_id1`),
S.divider(),
listDraftArticles(S, "ds_artikkel"),
listOutdatedArticles(S, "ds_artikkel", 180),
S.divider(),
...Panes("ds_artikkel", [...grunnleggendeKategorier], S),
]),
);
}
Loaded 100 of 132 files, more files were not shown because too many files have changed in this diff. Show more