Compare commits

...
Author SHA1 Message Date
github-actions[bot]andgithub-actions[bot] 638e8260b4 Version Packages (#2811)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-04-02 08:45:10 +02:00
Halvor Haugan d991011690 FileUpload.Dropzone: id prop settes på button (#2817) 2024-03-22 11:03:05 +01:00
KenandHalvor Haugan b6086f7b1f 📝 Oppdatert radio-error dokumentasjon (#2800)
* 📝 Oppdatert radio-error dokumentasjon

* Update aksel.nav.no/website/pages/eksempler/radio/error.tsx

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

* Update aksel.nav.no/website/pages/eksempler/radio/error.tsx

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

* Update aksel.nav.no/website/pages/eksempler/radio/error.tsx

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

---------

Co-authored-by: Halvor Haugan <83693529+HalvorHaugan@users.noreply.github.com>
2024-03-22 10:42:38 +01:00
Ken 05462081aa useControllableState i Expansioncard og Switch state-bug (#2810)
* ♻️ Bruker controllableState i ExpansionCard

* 🐛 Fikset tilfelle der checked switch var visuelt unchecked

* 📝 Changeset

* 🔥 Fjernet utkommentert kode
2024-03-22 10:24:35 +01:00
Ken 63e42de3ee 🆙 Oppdatert Playroom (#2815) 2024-03-22 09:31:00 +01:00
Halvor Haugan 2e85178166 yarn.lock sync 2024-03-21 16:36:11 +01:00
Ken 456d4e2e07 🐛 Slack var ikke i outbound accessPolicy for feedback 2024-03-21 11:52:48 +01:00
Ken b5a0cb8cdf Oppdatert logging for Aksel.nav.no + bumpet feedback pagination page-size (#2812)
* 🆙 Oppgradert logging

* 🎨 Bedre loggeoutput

* 🎨 Bruker childlogger

* 🧪 Custome error-logg

* ⚡ Større paginering-page for slackusers

* 📝 error -> info
2024-03-21 11:36:43 +01:00
Ken a2af9bcc37 Lagt til mer padding-right på ReadMore (#2806)
* 💄 Oppdatert padding-right på readmore

* 📝 Changeset

* 🐛 Manglet chromatic-disable på readmore story
2024-03-21 10:16:14 +01:00
bd45754adb [aksel.nav.no] Slackbot for feedback (#2785)
* 🎉 Satt opp boilerplate for auth på Aksel

* 🎉 Auth boilerplate

* 🎨 Demo-setup for å teste auth i dev

* 🚚 Flyttet auth til getserversideprops

* 🔥 Fjernet authprovider fra _app

* 🐛 revalidate er ikke valid key for getserversideprops i nextjs

* 🚚 rename auth-dir

* 🔥 Fjernet auth types

* 🎉 Auth for api

* 🎨 Oppdatert workflows med token

* 🔥 Fjernet testing av auth på god-praksis sider

* 🎨 La til sidecar på prod-deploy

* 🐛 only return boolean in Promise<boolean>

* ✨ feedback form

* 🎨 showcase all states of new FeedBackForm

* 🔥 remove prop name (will use auth api)

* 🎨 use getAuthUser()

* 🎨 use useAuth() for login/logout

* 🎉 Slack feedback-api

* 🐛 handle case where token is null

* 🎨 scroll to anchor on login redirect

* ⚡ Utility-funksjoner for slack-api

* 🧪 Tests for slack-utilities

* 🎨 Ryddet opp i henting av slack memebers

* 🚚 Flyttet caching inn i funkson

* 📱 make FeedbackForm responsive

* 🎉 Strengere typet slack-utilities

* 🎨 Fikset typefeil i feedback-api

* ⬆️ Migrert til @navikt/oasis

* 🏷️ Satt typer til strict for å kjøre tester

* 🎨 Slackbot-API with protected auth and actual user

* 🏷️ strengere typer

* ⚡ Implementert paginering av slacbot

* 🎨 Lagt til slackbot-token i workflows

* 🏷️ strict tsconfig

* 🏷️ Typer for slackbot input

* 🔥 Fjernet demo-body

* 🎨 oppdatert medvirkende tekst

* ⬆️ Yarn lock

* 🎨 move state handling down to Feedback

* 🏷️ typing fixes

* 🚧 API usage

* 🏷️ fix type error

* 👷 update AccessPolicy

* 🦺 add validation to feedback form

* 💄 justify-items-stretch on TextArea needed 🤔

* ♻️ avoid prop drilling, use sanity data hook for document id

* 🔧 add auth to webpack config for storybook

* ♻️ Feedback form  (also add error state handling)

* 👷 add stories for Feedback form

* 🔊 log feedback results metadata

* ♻️ remove refs, use formData and event.currentTarget

* 🧪 retry flaky test

* ⚗️ test promises for dynamic import in test

* 🧪 flaky test config

* ⬆️ yarn lock sync

* ⬆️ yarn lock sync

* ♻️ Bruker nå egen userState-type for fortelle komponenter om bruker er logget inn eller ikke

* 🔥 remove unused CSS

* 📝 lagt til todods

* 🔥 remove anonymous feedback submission

* 🔥 remove CSS

* :goals_net: catch fetch clientside, feedback

* ✨ send feedback to private slack channel as well

notify everyone if there are 0 editors

* 💄 + ♿ aria-hiddens and better styling, Feedback

* ♻️ refactor + add more feedback (loading state + alert)

* ⚗️ test encoding uri for anchor (redirect)

* ⚗️ more testing URL redirect

* ⚗️ more testing

* ⚗️ testing

* ✅ stories for Feedback

* 🔒 sanitize user objects (then cache)

* 🏷️ type SanitizeUser in find-user

* 🐛 optionally render the message about zero editors

* ⚗️ more testing for URL redirect / fragment issue

* ⚗️ log the redirect URL

* ⚗️ more testing

* ♻️ Gjennbruker error-return for feedback-api

* 🧪 Tester alternativ løsning for scroll etter login

* 🐛 Query update slapte loop i useEffect

* 🧪 Tester alternativ løsning for scroll etter login

* 🚚 Flyttet autofocus inn i headingseksjon

* 🐛 Wonderwall fjerner & fra query

* 🐛 encodeUrl as defined in wonderwall docs

* 🧪 Reversert til anchor-løsning

* 🧪 Reversert til anchor-løsning med encoding

* 🐛 Fikset edgecase der asPath med allerede eksisterende hash brakk login-redirect

* 🔥 Cleanup etter anchor-testing

* ⬆️ yarn lock sync

* 🎨 Oppdatert til navikt channelid

* 📝 Endret på error-tekst i feedback

* Fjernet ts-expect-error

* ⬆️ yarn lock sync

* 🔥 Fjernet filterering på godkjent mail

* 📝 Oppdatert slackbot-block

---------

Co-authored-by: Julian Nymark <julian.nymark@nav.no>
Co-authored-by: Halvor Haugan <halvor.haugan@nav.no>
2024-03-20 15:48:49 +01:00
github-actions[bot]andgithub-actions[bot] a882a8a389 Version Packages (#2807)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-03-20 13:26:51 +01:00
Ken d1606d358a 🐛 useDatepicker håndterer isInvalid validation riktig (#2809)
* 🐛 useDatepicker håndterer isInvalid validation riktig nå

* Update @navikt/core/react/src/date/hooks/useDatepicker.tsx

* Update @navikt/core/react/src/date/hooks/useDatepicker.tsx
2024-03-20 13:21:05 +01:00
Ken bb87837632 🧪 Bump timeout and retry of dateStr testing 2024-03-20 12:33:56 +01:00
Halvor Haugan 9c8b2301ed FileUpload.Dropzone: Fiks feil med duplisert id (#2805) 2024-03-20 12:31:08 +01:00
Ken 54ba483d2d ✅ Oppdatert Chromatic-tester
✅ Oppdatert dropdown-tester

🐛 satt dropdown-story til open

✅ Oppdatert errorsummary-tester

✅ Oppdatert fieldset-tester
2024-03-20 11:51:46 +01:00
github-actions[bot]andgithub-actions[bot] 85f5458c06 Version Packages (#2804)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-03-20 09:51:54 +01:00
Halvor Haugan 19abec80f7 FileUpload: Fiks feil ved import av json (#2803) 2024-03-20 09:46:45 +01:00
Julian NymarkandHalvor Haugan b2ddce5279 📝 Search docs review (#2797)
Co-authored-by: Halvor Haugan <halvor.haugan@nav.no>
2024-03-19 15:39:12 +01:00
github-actions[bot]andgithub-actions[bot] bd7c945158 Version Packages (#2799)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-03-19 15:07:37 +01:00
Ken ca24684a25 🐛 Dropzone med disabled stoppet ikke filopplasting (#2798)
inputfelt settes nå til disabled ved bruk av prop for å unngå dette
2024-03-19 15:02:06 +01:00
Julian Nymark 1564363985 💄 break-normal on body text, but break-all on types, as they… (#2790)
* 💄 break-normal on body text, but break-all on types, as they get long and complicated without spaces

* 🔥 remove break-normal, as it's the default
2024-03-19 14:58:58 +01:00
Ken ca8d36e885 🔥 Fjernet UX-tweaks card på forside
Kode er kommentert ut fra forside, men vil være lett tilgjengelig hvis vi ønsker å legge til fremtidige undersøkelser
2024-03-19 14:43:00 +01:00
87 changed files with 2481 additions and 678 deletions

No files matched your search

+1
View File
@@ -25,6 +25,7 @@ jobs:
- name: add tokens to .env
run: |
echo "SLACK_BOT_TOKEN=${{ secrets.SLACK_BOT_TOKEN }}" >> aksel.nav.no/website/.env
echo "SANITY_WRITE_KEY=${{ secrets.SANITY_FEEDBACK_WRITE_KEY }}" >> aksel.nav.no/website/.env
echo "SANITY_PREVIEW_TOKEN=${{ secrets.SANITY_PREVIEW_TOKEN }}" >> aksel.nav.no/website/.env
echo "SANITY_PRIVATE_NO_DRAFTS=${{ secrets.SANITY_PRIVATE_NO_DRAFTS }}" >> aksel.nav.no/website/.env
+1
View File
@@ -59,6 +59,7 @@ jobs:
- name: add tokens to .env
run: |
echo "SLACK_BOT_TOKEN=${{ secrets.SLACK_BOT_TOKEN }}" >> aksel.nav.no/website/.env
echo "SANITY_WRITE_KEY=${{ secrets.SANITY_FEEDBACK_WRITE_KEY }}" >> aksel.nav.no/website/.env
echo "SANITY_PREVIEW_TOKEN=${{ secrets.SANITY_PREVIEW_TOKEN }}" >> aksel.nav.no/website/.env
echo "SANITY_PRIVATE_NO_DRAFTS=${{ secrets.SANITY_PRIVATE_NO_DRAFTS }}" >> aksel.nav.no/website/.env
+8
View File
@@ -1,5 +1,13 @@
# @navikt/aksel-icons
## 6.3.4
## 6.3.3
## 6.3.2
## 6.3.1
## 6.3.0
## 6.2.0
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/aksel-icons",
"version": "6.3.0",
"version": "6.3.4",
"author": "Aksel | NAV",
"homepage": "https://aksel.nav.no/ikoner",
"repository": {
+8
View File
@@ -1,5 +1,13 @@
# @navikt/aksel-stylelint
## 6.3.4
## 6.3.3
## 6.3.2
## 6.3.1
## 6.3.0
## 6.2.0
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/aksel-stylelint",
"version": "6.3.0",
"version": "6.3.4",
"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.3.0",
"@navikt/ds-tokens": "^6.3.0",
"@navikt/ds-css": "^6.3.4",
"@navikt/ds-tokens": "^6.3.4",
"concurrently": "7.2.1",
"postcss-selector-parser": "^6.0.13",
"postcss-value-parser": "^4.2.0",
+28
View File
@@ -1,5 +1,33 @@
# @navikt/aksel
## 6.3.4
### Patch Changes
- Updated dependencies [[`a2af9bcc3`](https://github.com/navikt/aksel/commit/a2af9bcc37eb12cc7cf3a1596d57dc0421898657)]:
- @navikt/ds-css@6.3.4
## 6.3.3
### Patch Changes
- Updated dependencies []:
- @navikt/ds-css@6.3.3
## 6.3.2
### Patch Changes
- Updated dependencies []:
- @navikt/ds-css@6.3.2
## 6.3.1
### Patch Changes
- Updated dependencies []:
- @navikt/ds-css@6.3.1
## 6.3.0
### Patch Changes
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/aksel",
"version": "6.3.0",
"version": "6.3.4",
"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.3.0",
"@navikt/ds-css": "6.3.4",
"axios": "1.6.0",
"chalk": "4.1.0",
"clipboardy": "^2.3.0",
+12
View File
@@ -1,5 +1,17 @@
# @navikt/ds-css
## 6.3.4
### Patch Changes
- ReadMore: Padding-right på button er bumpet noen px ([#2806](https://github.com/navikt/aksel/pull/2806))
## 6.3.3
## 6.3.2
## 6.3.1
## 6.3.0
### Minor Changes
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/ds-css",
"version": "6.3.0",
"version": "6.3.4",
"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.3.0",
"@navikt/ds-tokens": "^6.3.4",
"cssnano": "6.0.0",
"fast-glob": "3.2.11",
"lodash": "4.17.21",
+2 -2
View File
@@ -8,12 +8,12 @@
gap: var(--a-spacing-05);
color: var(--ac-read-more-text, var(--a-text-action));
border-radius: var(--a-border-radius-small);
padding: var(--a-spacing-1) var(--a-spacing-1) var(--a-spacing-1) var(--a-spacing-05);
padding: var(--a-spacing-1) var(--a-spacing-2) var(--a-spacing-1) var(--a-spacing-05);
text-align: start;
}
.navds-read-more--small .navds-read-more__button {
padding: var(--a-spacing-05) var(--a-spacing-1) var(--a-spacing-05) var(--a-spacing-05);
padding: var(--a-spacing-05) var(--a-spacing-1-alt) var(--a-spacing-05) var(--a-spacing-05);
}
@media (forced-colors: active) {
+44
View File
@@ -1,5 +1,49 @@
# @navikt/ds-react
## 6.3.4
### Patch Changes
- Switch: Fikset edgecase der checked Switch var visuelt unchecked ([#2810](https://github.com/navikt/aksel/pull/2810))
- FileUpload.Dropzone: id prop settes på button ([#2817](https://github.com/navikt/aksel/pull/2817))
- Updated dependencies []:
- @navikt/ds-tokens@6.3.4
- @navikt/aksel-icons@6.3.4
## 6.3.3
### Patch Changes
- FileUpload.Dropzone: Fiks feil med duplisert id ([#2805](https://github.com/navikt/aksel/pull/2805))
- useDatePicker: `isInvalid`-key i onValidate var hadde flipper boolean i noen tilfeller ([#2809](https://github.com/navikt/aksel/pull/2809))
- Updated dependencies []:
- @navikt/ds-tokens@6.3.3
- @navikt/aksel-icons@6.3.3
## 6.3.2
### Patch Changes
- FileUpload: Fiks feil ved import av json ([#2803](https://github.com/navikt/aksel/pull/2803))
- Updated dependencies []:
- @navikt/ds-tokens@6.3.2
- @navikt/aksel-icons@6.3.2
## 6.3.1
### Patch Changes
- FileUpload: Disabled dropzone stopper opplasting ([#2798](https://github.com/navikt/aksel/pull/2798))
- Updated dependencies []:
- @navikt/ds-tokens@6.3.1
- @navikt/aksel-icons@6.3.1
## 6.3.0
### Minor Changes
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/ds-react",
"version": "6.3.0",
"version": "6.3.4",
"description": "React components from the Norwegian Labour and Welfare Administration.",
"author": "Aksel, a team part of the Norwegian Labour and Welfare Administration.",
"license": "MIT",
@@ -562,8 +562,8 @@
},
"dependencies": {
"@floating-ui/react": "0.25.4",
"@navikt/aksel-icons": "^6.3.0",
"@navikt/ds-tokens": "^6.3.0",
"@navikt/aksel-icons": "^6.3.4",
"@navikt/ds-tokens": "^6.3.4",
"@radix-ui/react-tabs": "1.0.0",
"@radix-ui/react-toggle-group": "1.0.0",
"clsx": "^2.1.0",
@@ -160,6 +160,7 @@ export const UseDatepicker = () => {
fromDate: new Date("Aug 23 2019"),
toDate: new Date("Feb 23 2024"),
onDateChange: console.log,
onValidate: console.log,
});
return (
@@ -272,7 +272,7 @@ export const useDatepicker = (
) {
updateDate(undefined);
updateValidation({
isInvalid: isValidDate(day),
isInvalid: !isValidDate(day),
isWeekend: disableWeekends && isWeekend(day),
isDisabled: disabled && isMatch(day, disabled),
isValidDate: false,
@@ -1,4 +1,4 @@
import { Meta } from "@storybook/react";
import { Meta, StoryObj } from "@storybook/react";
import React, { useState } from "react";
import { Button } from "../button";
import Dropdown from "./Dropdown";
@@ -6,10 +6,15 @@ import Dropdown from "./Dropdown";
export default {
title: "ds-react/Dropdown",
component: Dropdown,
} as Meta;
parameters: {
chromatic: { disable: true },
},
} satisfies Meta<typeof Dropdown>;
export const Default = () => {
return (
type Story = StoryObj<typeof Dropdown>;
export const Default: Story = {
render: () => (
<Dropdown onSelect={(event) => console.log(event)}>
<Dropdown.Toggle>Toggle</Dropdown.Toggle>
<Dropdown.Menu strategy="fixed">
@@ -33,10 +38,10 @@ export const Default = () => {
</Dropdown.Menu.List>
</Dropdown.Menu>
</Dropdown>
);
),
};
export const DefaultOpen = {
export const DefaultOpen: Story = {
render: () => (
<Dropdown onSelect={(event) => console.log(event)} defaultOpen>
<Button as={Dropdown.Toggle}>Toggle</Button>
@@ -57,20 +62,41 @@ export const DefaultOpen = {
</Dropdown.Menu>
</Dropdown>
),
args: { chromatic: { delay: 300 } },
};
export const ControlledOpen = () => {
const [openState, setOpenState] = useState(true);
return (
<Dropdown onSelect={(event) => console.log(event)} open={openState}>
<Button as={Dropdown.Toggle} onClick={() => setOpenState(!openState)}>
Toggle
</Button>
<Dropdown.Menu
strategy="fixed"
onClose={() => console.log("ONCLOSE CONTROLLED")}
>
export const ControlledOpen: Story = {
render: () => {
const [openState, setOpenState] = useState(true);
return (
<Dropdown onSelect={(event) => console.log(event)} open={openState}>
<Button as={Dropdown.Toggle} onClick={() => setOpenState(!openState)}>
Toggle
</Button>
<Dropdown.Menu
strategy="fixed"
onClose={() => console.log("ONCLOSE CONTROLLED")}
>
<Dropdown.Menu.GroupedList>
<Dropdown.Menu.GroupedList.Heading>
Systemer og oppslagsverk
</Dropdown.Menu.GroupedList.Heading>
<Dropdown.Menu.GroupedList.Item
onClick={() => console.log("GroupedList.Item-click")}
>
Gosys
</Dropdown.Menu.GroupedList.Item>
</Dropdown.Menu.GroupedList>
</Dropdown.Menu>
</Dropdown>
);
},
};
export const Chromatic: Story = {
render: () => (
<Dropdown onSelect={(event) => console.log(event)} open>
<Dropdown.Toggle>Toggle</Dropdown.Toggle>
<Dropdown.Menu strategy="fixed">
<Dropdown.Menu.GroupedList>
<Dropdown.Menu.GroupedList.Heading>
Systemer og oppslagsverk
@@ -81,8 +107,18 @@ export const ControlledOpen = () => {
Gosys
</Dropdown.Menu.GroupedList.Item>
</Dropdown.Menu.GroupedList>
<Dropdown.Menu.Divider />
<Dropdown.Menu.List>
<Dropdown.Menu.List.Item onClick={() => console.log("Item-click")}>
Gosys
</Dropdown.Menu.List.Item>
<Dropdown.Menu.List.Item>Psys</Dropdown.Menu.List.Item>
<Dropdown.Menu.List.Item disabled>Infotrygd</Dropdown.Menu.List.Item>
</Dropdown.Menu.List>
</Dropdown.Menu>
</Dropdown>
);
),
parameters: {
chromatic: { disable: false, delay: 300 },
},
};
ControlledOpen.args = { chromatic: { delay: 300 } };
@@ -1,5 +1,6 @@
import cl from "clsx";
import React, { forwardRef, useRef, useState } from "react";
import React, { forwardRef, useRef } from "react";
import { useControllableState } from "../util/hooks";
import { OverridableComponent } from "../util/types";
import ExpansionCardContent, {
ExpansionCardContentProps,
@@ -116,24 +117,24 @@ export const ExpansionCard = forwardRef<HTMLDivElement, ExpansionCardProps>(
},
ref,
) => {
const [_open, _setOpen] = useState(defaultOpen);
const shouldFade = useRef<boolean>(!(Boolean(open) || defaultOpen));
const handleOpen = () => {
if (open === undefined) {
const newOpen = !_open;
_setOpen(newOpen);
onToggle?.(newOpen);
} else {
onToggle?.(!open);
}
shouldFade.current = true;
};
const [_open, _setOpen] = useControllableState({
value: open,
onChange: (newValue) => {
onToggle?.(newValue);
shouldFade.current = true;
},
defaultValue: defaultOpen,
});
return (
<ExpansionCardContext.Provider
value={{ open: open ?? _open, toggleOpen: handleOpen, size }}
value={{
open: open ?? _open,
toggleOpen: () => _setOpen((x) => !x),
size,
}}
>
<section
{...rest}
@@ -31,7 +31,7 @@ export interface CheckboxProps
*/
indeterminate?: boolean;
/**
* Adds a description to extend labling of Checkbox.
* Adds a description to extend labeling of Checkbox.
*/
description?: string;
}
@@ -1,5 +1,6 @@
import { Meta } from "@storybook/react";
import { Meta, StoryObj } from "@storybook/react";
import React from "react";
import { VStack } from "../../layout/stack";
import { ErrorSummary } from "./ErrorSummary";
export default {
@@ -18,9 +19,14 @@ export default {
},
},
},
} as Meta;
parameters: {
chromatic: { disable: true },
},
} satisfies Meta<typeof ErrorSummary>;
export const Default = {
type Story = StoryObj<typeof ErrorSummary>;
export const Default: Story = {
render: (props) => (
<ErrorSummary
heading="Feiloppsummering komponent"
@@ -35,11 +41,41 @@ export const Default = {
),
};
export const Small = () => (
<ErrorSummary heading="Feiloppsummering komponent" size="small">
<ErrorSummary.Item href="#1">Checkbox må fylles ut</ErrorSummary.Item>
<ErrorSummary.Item href="#2">
Tekstfeltet må ha en godkjent e-mail
</ErrorSummary.Item>
</ErrorSummary>
);
export const Small: Story = {
render: () => (
<ErrorSummary heading="Feiloppsummering komponent" size="small">
<ErrorSummary.Item href="#1">Checkbox må fylles ut</ErrorSummary.Item>
<ErrorSummary.Item href="#2">
Tekstfeltet må ha en godkjent e-mail
</ErrorSummary.Item>
</ErrorSummary>
),
};
export const Chromatic: Story = {
render: () => (
<VStack gap="4">
<div>
<h2>Default</h2>
<ErrorSummary heading="Feiloppsummering komponent">
<ErrorSummary.Item href="#1">Checkbox må fylles ut</ErrorSummary.Item>
<ErrorSummary.Item href="#2">
Tekstfeltet må ha en godkjent e-mail
</ErrorSummary.Item>
</ErrorSummary>
</div>
<div>
<h2>Small</h2>
<ErrorSummary heading="Feiloppsummering komponent" size="small">
<ErrorSummary.Item href="#1">Checkbox må fylles ut</ErrorSummary.Item>
<ErrorSummary.Item href="#2">
Tekstfeltet må ha en godkjent e-mail
</ErrorSummary.Item>
</ErrorSummary>
</div>
</VStack>
),
parameters: {
chromatic: { disable: false },
},
};
@@ -1,5 +1,6 @@
import { Meta } from "@storybook/react";
import { Meta, StoryObj } from "@storybook/react";
import React from "react";
import { VStack } from "../../layout/stack";
import TextField from "../textfield/TextField";
import { Fieldset } from "./index";
@@ -26,7 +27,12 @@ export default {
type: "boolean",
},
},
} as Meta;
parameters: {
chromatic: { disable: true },
},
} satisfies Meta<typeof Fieldset>;
type Story = StoryObj<typeof Fieldset>;
const content = (
<>
@@ -35,88 +41,117 @@ const content = (
</>
);
export const Default = {
render: (props) => {
return (
<Fieldset legend="Mollit eiusmod" {...props}>
{content}
</Fieldset>
);
},
const contentWithError = (
<>
<TextField label="Textfield label" hideLabel error="Må være fylt ut" />
<TextField label="Textfield label" hideLabel />
</>
);
export const Default: Story = {
args: {
legend: "Mollit eiusmod",
description:
"Do ullamco amet mollit labore tempor minim cupidatat dolore voluptate velit irure.",
errorPropagation: true,
children: content,
},
};
export const Small = () => (
<Fieldset
size="small"
legend="Mollit eiusmod"
description="Do ullamco amet mollit labore tempor minim cupidatat dolore voluptate velit irure."
>
{content}
</Fieldset>
);
export const Small: Story = {
args: {
legend: "Mollit eiusmod",
description:
"Do ullamco amet mollit labore tempor minim cupidatat dolore voluptate velit irure.",
errorPropagation: true,
children: content,
size: "small",
},
};
export const Description = () => (
<Fieldset
legend="Mollit eiusmod"
description="Esse cupidatat reprehenderit est culpa consectetur sit dolor esse."
>
<TextField
label="Textfield label"
description="Amet quis cillum incididunt "
/>
<TextField
label="Textfield label"
description="Enim et occaecat voluptate labore sit do exercitation laborum non "
/>
</Fieldset>
);
export const ErrorPropagation: Story = {
args: {
legend: "Mollit eiusmod",
description:
"Do ullamco amet mollit labore tempor minim cupidatat dolore voluptate velit irure.",
errorPropagation: false,
children: contentWithError,
},
};
export const ErrorPropagation = () => (
<Fieldset
legend="Mollit eiusmod"
error="Fieldsett error"
errorPropagation={false}
>
<TextField label="Textfield label" hideLabel error="Må være fylt ut" />
<TextField label="Textfield label" hideLabel />
</Fieldset>
);
export const Error: Story = {
args: {
legend: "Mollit eiusmod",
description:
"Do ullamco amet mollit labore tempor minim cupidatat dolore voluptate velit irure.",
children: content,
error: "Laborum officia nisi aliqua esse minim in amet.",
},
};
export const Error = () => (
<div className="colgap">
<Fieldset
legend="Mollit eiusmod"
error="Laborum officia nisi aliqua esse minim in amet."
>
{content}
</Fieldset>
<Fieldset
size="small"
legend="Mollit eiusmod"
error="Laborum officia nisi aliqua esse minim in amet."
>
{content}
</Fieldset>
</div>
);
export const Disabled: Story = {
args: {
legend: "Mollit eiusmod",
description:
"Do ullamco amet mollit labore tempor minim cupidatat dolore voluptate velit irure.",
children: content,
disabled: true,
},
};
export const Disabled = () => (
<div className="colgap">
<Fieldset legend="Mollit eiusmod" disabled>
{content}
</Fieldset>
<Fieldset size="small" legend="Mollit eiusmod" disabled>
{content}
</Fieldset>
</div>
);
export const HideLegend: Story = {
args: {
legend: "Mollit eiusmod",
description:
"Do ullamco amet mollit labore tempor minim cupidatat dolore voluptate velit irure.",
children: content,
hideLegend: true,
},
};
export const HideLegend = () => (
<Fieldset legend="Mollit eiusmod" hideLegend>
{content}
</Fieldset>
);
export const Chromatic: Story = {
render: () => {
return (
<VStack gap="4">
<div>
<h2>Default</h2>
{/* @ts-expect-error Args are Partial here */}
<Fieldset {...Default.args} />
</div>
<div>
<h2>Small</h2>
{/* @ts-expect-error Args are Partial here */}
<Fieldset {...Small.args} />
</div>
<div>
<h2>ErrorPropagation</h2>
{/* @ts-expect-error Args are Partial here */}
<Fieldset {...ErrorPropagation.args} />
</div>
<div>
<h2>Error</h2>
{/* @ts-expect-error Args are Partial here */}
<Fieldset {...Error.args} />
</div>
<div>
<h2>Error small</h2>
{/* @ts-expect-error Args are Partial here */}
<Fieldset {...Error.args} size="small" />
</div>
<div>
<h2>Disabled</h2>
{/* @ts-expect-error Args are Partial here */}
<Fieldset {...Disabled.args} />
</div>
<div>
<h2>HideLegend</h2>
{/* @ts-expect-error Args are Partial here */}
<Fieldset {...HideLegend.args} />
</div>
</VStack>
);
},
parameters: {
chromatic: { disable: false },
},
};
@@ -5,7 +5,7 @@ import {
ComponentTranslation,
TranslationDictionary,
} from "./i18n.types";
import nb from "./locales/nb.json";
import nb from "./locales/nb";
/**
* https://regex101.com/r/LYKWi3/1
@@ -1,4 +1,4 @@
import nb from "./locales/nb.json";
import nb from "./locales/nb";
export interface TranslationObject {
[key: string]: string | TranslationObject | undefined;
@@ -1,20 +0,0 @@
{
"FileUpload": {
"dropzone": {
"button": "Velg fil",
"buttonMultiple": "Velg filer",
"dragAndDrop": "Dra og slipp filen her",
"dragAndDropMultiple": "Dra og slipp filer her",
"drop": "Slipp",
"or": "eller",
"disabled": "Filopplasting er deaktivert",
"disabledFilelimit": "Du kan ikke laste opp flere filer"
},
"item": {
"retryButtonTitle": "Prøv å laste opp filen på nytt",
"deleteButtonTitle": "Slett filen",
"uploading": "Laster opp…",
"downloading": "Laster ned…"
}
}
}
@@ -0,0 +1,20 @@
export default {
FileUpload: {
dropzone: {
button: "Velg fil",
buttonMultiple: "Velg filer",
dragAndDrop: "Dra og slipp filen her",
dragAndDropMultiple: "Dra og slipp filer her",
drop: "Slipp",
or: "eller",
disabled: "Filopplasting er deaktivert",
disabledFilelimit: "Du kan ikke laste opp flere filer",
},
item: {
retryButtonTitle: "Prøv å laste opp filen på nytt",
deleteButtonTitle: "Slett filen",
uploading: "Laster opp…",
downloading: "Laster ned…",
},
},
};
@@ -30,6 +30,7 @@ const Dropzone = forwardRef<HTMLInputElement, FileUploadDropzoneProps>(
disabled,
translations,
onClick,
id: buttonId,
...rest
} = props;
@@ -46,7 +47,10 @@ const Dropzone = forwardRef<HTMLInputElement, FileUploadDropzoneProps>(
const _disabled = disabled ?? fileLimitReached;
const { inputProps, errorId, showErrorMsg, hasError, inputDescriptionId } =
useFormField({ ...props, disabled: _disabled }, "fileUpload");
useFormField(
{ ...omit(props, ["id"]), disabled: _disabled },
"fileUpload",
);
const {
id: inputId,
"aria-describedby": ariaDescribedby,
@@ -131,6 +135,7 @@ const Dropzone = forwardRef<HTMLInputElement, FileUploadDropzoneProps>(
<Button
{...omit(rest, ["errorId"])}
{...inputPropsRest}
id={buttonId}
aria-describedby={cl(labelId, ariaDescribedby)}
className="navds-dropzone__area-button"
type="button"
@@ -162,6 +167,7 @@ const Dropzone = forwardRef<HTMLInputElement, FileUploadDropzoneProps>(
accept={accept}
onChange={onChange}
ref={mergedRef}
disabled={inputProps.disabled}
/>
</div>
<div
@@ -24,12 +24,14 @@ export interface FileItemBaseProps {
file: FileItem;
/**
* onClick on the file name.
* @note If this and `href` is not set and the `file` prop is a native file, onClick will download the file.
*
* If neither this nor `href` is set, and the `file` prop is a native file, onClick will download the file.
*/
onFileClick?: (event: MouseEvent<HTMLAnchorElement>) => void;
/**
* href on the file name.
* @note If this and `onFileClick` is not set and the `file` prop is a native file, onClick will download the file.
*
* If neither this nor `onFileClick` is set, and the `file` prop is a native file, onClick will download the file.
*/
href?: string;
/**
@@ -49,6 +49,7 @@ export const Default = (props) => {
</RadioGroup>
);
};
Default.args = {
controlled: false,
error: false,
+3 -3
View File
@@ -5,15 +5,15 @@ export interface RadioProps
extends Omit<FormFieldProps, "error" | "errorId" | "readOnly">,
Omit<InputHTMLAttributes<HTMLInputElement>, "size" | "value"> {
/**
* Radio label
* Radio label.
*/
children: React.ReactNode;
/**
* The value of the HTML element
* The value of the HTML element.
*/
value: any;
/**
* Adds a description to extend labling of Radio
* Adds a description to extend labeling of Radio.
*/
description?: string;
}
+15 -11
View File
@@ -28,47 +28,51 @@ export interface SearchProps
/**
* Search label.
*
* Will be hidden by default, is required for accessibility reasons.
* Will be hidden by default and is required for WCAG compliance.
*/
label: React.ReactNode;
/**
* Shows label and description for screenreaders-only
* Shows label and description for screenreaders only.
* @default true
*/
hideLabel?: boolean;
/**
* Callback for value-change in input
* Callback for value-change in input.
*/
onChange?: (value: string) => void;
/**
* Callback for click on clear-button or Escape keydown
* Callback for click on clear-button or Escape keydown.
*/
onClear?: (e: SearchClearEvent) => void;
/**
* Callback for Search-button submit
* Callback for Search-button submit.
*/
onSearchClick?: (value: string) => void;
/**
* aria-label on clear button
* Sets the `aria-label` for the clear button.
* @default "Tøm"
*/
clearButtonLabel?: string;
/**
* If false, removes clear-button option from input.
* Removes clear-button if `false`.
* @default true
*/
clearButton?: boolean;
/**
* Changes button-variant, "simple" removes button
* Changes button-variant.
*
* - "primary": When this is the main function of the page.
* - "secondary": This is probably the one you want if in doubt.
* - "simple": Removes the search button.
* @default "primary"
*/
variant?: "primary" | "secondary" | "simple";
/**
* Exposes the HTML size attribute
* Exposes the HTML size attribute. Specifies the width of the element, in characters.
*/
htmlSize?: number | string;
/*
* Exposes role attribute
* Exposes role attribute.
*/
role?: string;
}
@@ -88,7 +92,7 @@ interface SearchComponent
*
* @example
* ```jsx
* <form>
* <form role="search">
* <Search label="Søk alle NAV sine sider" variant="primary" />
* </form>
* ```
@@ -53,7 +53,7 @@ export interface SwitchProps
*/
position?: "left" | "right";
/**
* Adds a description to extend labling of Switch.
* Adds a description to extend labeling of Switch.
*/
description?: string;
}
@@ -85,7 +85,7 @@ export const Switch = forwardRef<HTMLInputElement, SwitchProps>(
...rest
} = props;
const [checked, setChecked] = useState(
const [_checked, setChecked] = useState(
defaultChecked ?? checkedProp ?? false,
);
@@ -93,6 +93,8 @@ export const Switch = forwardRef<HTMLInputElement, SwitchProps>(
checkedProp !== undefined && setChecked(checkedProp);
}, [checkedProp]);
const checked = checkedProp ?? _checked;
return (
<div
className={cl(
@@ -1,5 +1,6 @@
import { Meta } from "@storybook/react";
import { Meta, StoryObj } from "@storybook/react";
import React from "react";
import { VStack } from "../../layout/stack";
import Switch from "./Switch";
export default {
@@ -31,70 +32,59 @@ export default {
type: "boolean",
},
},
} as Meta;
export const Default = {
render: (props) => {
return <Switch {...props}>Label text</Switch>;
parameters: {
chromatic: { disable: true },
},
} satisfies Meta<typeof Switch>;
type Story = StoryObj<typeof Switch>;
export const Default: Story = {
args: {
position: "right",
children: "Label text",
},
};
export const Small = () => {
return (
<div className="colgap">
<Switch size="small">Label text</Switch>
<Switch size="small" position="right">
Label text
</Switch>
</div>
);
export const Small: Story = {
args: {
children: "Label text",
size: "small",
},
};
export const Description = () => {
return (
<div className="colgap">
<Switch size="small" description="Cillum sint exercitation ut cillum.">
Label text
</Switch>
<Switch
size="small"
position="right"
description="Cillum sint exercitation ut cillum."
>
Label text
</Switch>
</div>
);
export const Description: Story = {
args: {
children: "Label text",
description: "Cillum sint exercitation ut cillum.",
},
};
export const Loading = () => {
return (
<div className="colgap">
export const Loading: Story = {
render: () => {
return (
<div className="colgap">
<Switch loading>Label text</Switch>
<div className="colgap">
<Switch loading>Label text</Switch>
<Switch checked loading>
Label text
</Switch>
<Switch checked loading>
Label text
</Switch>
</div>
<div className="colgap">
<Switch loading size="small">
Label text
</Switch>
<Switch checked loading size="small">
Label text
</Switch>
</div>
</div>
<div className="colgap">
<Switch loading size="small">
Label text
</Switch>
<Switch checked loading size="small">
Label text
</Switch>
</div>
</div>
);
);
},
};
export const Disabled = () => {
return (
export const Disabled: Story = {
render: () => (
<div className="colgap">
<Switch disabled>Label text</Switch>
@@ -102,11 +92,11 @@ export const Disabled = () => {
Label text
</Switch>
</div>
);
),
};
export const HideLabel = () => {
return (
export const HideLabel: Story = {
render: () => (
<div className="colgap">
<Switch hideLabel>Label text</Switch>
@@ -114,11 +104,11 @@ export const HideLabel = () => {
Label text
</Switch>
</div>
);
),
};
export const Readonly = () => {
return (
export const Readonly: Story = {
render: () => (
<div className="colgap">
<Switch description="Slår av alle notifikasjoner" readOnly>
Notifikasjoner
@@ -128,5 +118,54 @@ export const Readonly = () => {
Notifikasjoner
</Switch>
</div>
);
),
};
export const Chromatic: Story = {
render: () => (
<VStack gap="4" align="start">
<div>
<h2>Default</h2>
{/* @ts-expect-error Args are Partial here */}
<Switch {...Default.args} />
</div>
<div>
<h2>Small</h2>
{/* @ts-expect-error Args are Partial here */}
<Switch {...Small.args} position="left" />
{/* @ts-expect-error Args are Partial here */}
<Switch {...Small.args} position="right" />
</div>
<div>
<h2>Description</h2>
{/* @ts-expect-error Args are Partial here */}
<Switch {...Description.args} position="left" />
{/* @ts-expect-error Args are Partial here */}
<Switch {...Description.args} position="right" />
</div>
<div>
<h2>Loading</h2>
{/* @ts-expect-error Args are Partial here */}
<Loading.render />
</div>
<div>
<h2>Disabled</h2>
{/* @ts-expect-error Args are Partial here */}
<Disabled.render />
</div>
<div>
<h2>HideLabel</h2>
{/* @ts-expect-error Args are Partial here */}
<HideLabel.render />
</div>
<div>
<h2>Readonly</h2>
{/* @ts-expect-error Args are Partial here */}
<Readonly.render />
</div>
</VStack>
),
parameters: {
chromatic: { disable: false },
},
};
+2 -2
View File
@@ -5,7 +5,7 @@ import { FieldsetContext } from "./fieldset/context";
export interface FormFieldProps {
/**
* Adds a description to extend labling of the field.
* Adds a description to extend labeling of the field.
* Error message for element.
*/
error?: React.ReactNode;
@@ -24,7 +24,7 @@ export interface FormFieldProps {
*/
disabled?: boolean;
/**
* Adds a description to extend labling of a field.
* Adds a description to extend labeling of a field.
*/
description?: React.ReactNode;
/**
@@ -1,65 +1,91 @@
import { Meta, StoryFn } from "@storybook/react";
import React, { useState } from "react";
import { Meta, StoryObj } from "@storybook/react";
import { fn } from "@storybook/test";
import React from "react";
import { ReadMore } from ".";
import { VStack } from "../layout/stack";
const meta: Meta<typeof ReadMore> = {
export default {
title: "ds-react/ReadMore",
component: ReadMore,
};
export default meta;
parameters: {
chromatic: { disable: true },
},
} satisfies Meta<typeof ReadMore>;
export const Default: StoryFn<{
controlled: boolean;
size: "medium" | "small";
defaultOpen: boolean;
}> = (props) => {
const [state, setState] = useState(false);
type Story = StoryObj<typeof ReadMore>;
return (
<ReadMore
open={props.controlled ? state : undefined}
onClick={() => setState((x) => !x)}
header="Grunnen til at vi spør om dette og i tillegg ber om vedlegg"
size={props.size}
onOpenChange={console.log}
defaultOpen={props.defaultOpen}
>
<div style={{ maxWidth: 300 }}>
Lorem ipsum dolor sit amet consectetur adipisicing elit. Mollitia,
tempore corporis exercitationem minus dignissimos eius aspernatur fugiat
iusto.
</div>
</ReadMore>
);
};
Default.args = {
controlled: false,
size: "medium",
defaultOpen: false,
};
Default.argTypes = {
size: {
options: ["medium", "small"],
control: { type: "radio" },
const Content = (
<div style={{ maxWidth: 300 }}>
Lorem ipsum dolor sit amet consectetur adipisicing elit. Mollitia, tempore
corporis exercitationem minus dignissimos eius aspernatur fugiat iusto.
</div>
);
export const Default: Story = {
args: {
size: "medium",
defaultOpen: false,
onOpenChange: fn(),
children: Content,
header: "Grunnen til at vi spør om dette og i tillegg ber om vedlegg",
},
argTypes: {
size: {
options: ["medium", "small"],
control: { type: "radio" },
},
},
};
export const Small = () => (
<ReadMore
header="Grunnen til at vi spør om dette og i tillegg ber om vedlegg"
size="small"
>
Command station, this is ST 321. Code Clearance Blue. We&apos;re starting
our approach. Deactivate the security shield.
</ReadMore>
);
export const Small: Story = {
args: {
...Default.args,
size: "small",
},
};
export const Open = () => (
<ReadMore
header="Grunnen til at vi spør om dette og i tillegg ber om vedlegg"
defaultOpen
>
Command station, this is ST 321. Code Clearance Blue. We&apos;re starting
our approach. Deactivate the security shield.
</ReadMore>
);
export const DefaultOpen: Story = {
args: {
...Default.args,
defaultOpen: true,
},
};
export const Open: Story = {
args: {
...Default.args,
open: true,
},
};
export const Chromatic: Story = {
render: () => {
return (
<VStack gap="4">
<div>
<h2>Default</h2>
{/* @ts-expect-error Args are partial, leading to required prop mismatch */}
<ReadMore {...Default.args} />
</div>
<div>
<h2>Small</h2>
{/* @ts-expect-error Args are partial, leading to required prop mismatch */}
<ReadMore {...Small.args} />
</div>
<div>
<h2>DefaultOpen</h2>
{/* @ts-expect-error Args are partial, leading to required prop mismatch */}
<ReadMore {...DefaultOpen.args} />
</div>
<div>
<h2>Controlled open</h2>
{/* @ts-expect-error Args are partial, leading to required prop mismatch */}
<ReadMore {...Open.args} />
</div>
</VStack>
);
},
parameters: {
chromatic: { disable: false },
},
};
+8
View File
@@ -1,5 +1,13 @@
# @navikt/ds-tailwind
## 6.3.4
## 6.3.3
## 6.3.2
## 6.3.1
## 6.3.0
## 6.2.0
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/ds-tailwind",
"version": "6.3.0",
"version": "6.3.4",
"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.3.0",
"@navikt/ds-tokens": "^6.3.4",
"color": "4.2.3",
"lodash": "^4.17.21",
"tailwindcss": "^3.3.3",
+8
View File
@@ -1,5 +1,13 @@
# @navikt/ds-tokens
## 6.3.4
## 6.3.3
## 6.3.2
## 6.3.1
## 6.3.0
## 6.2.0
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@navikt/ds-tokens",
"version": "6.3.0",
"version": "6.3.4",
"description": "Design-tokens for NAV designsystem",
"author": "Aksel | NAV designsystem team",
"keywords": [
+15 -1
View File
@@ -4,10 +4,24 @@
### @navikt/ds-css
- FileUpload: Ny komponent `FileUpload` for å håndtere opplasting av filer, Dropzone og fil-visning ([#2504](https://github.com/navikt/aksel/pull/2504))
- ReadMore: Padding-right på button er bumpet noen px ([#2806](https://github.com/navikt/aksel/pull/2806))
* FileUpload: Ny komponent `FileUpload` for å håndtere opplasting av filer, Dropzone og fil-visning ([#2504](https://github.com/navikt/aksel/pull/2504))
### @navikt/ds-react
- Switch: Fikset edgecase der checked Switch var visuelt unchecked ([#2810](https://github.com/navikt/aksel/pull/2810))
- FileUpload.Dropzone: id prop settes på button ([#2817](https://github.com/navikt/aksel/pull/2817))
* FileUpload.Dropzone: Fiks feil med duplisert id ([#2805](https://github.com/navikt/aksel/pull/2805))
* useDatePicker: `isInvalid`-key i onValidate var hadde flipper boolean i noen tilfeller ([#2809](https://github.com/navikt/aksel/pull/2809))
- FileUpload: Fiks feil ved import av json ([#2803](https://github.com/navikt/aksel/pull/2803))
* FileUpload: Disabled dropzone stopper opplasting ([#2798](https://github.com/navikt/aksel/pull/2798))
- Allow Combobox options as objects to support separate display text and value ([#2716](https://github.com/navikt/aksel/pull/2716))
- FileUpload: Ny komponent `FileUpload` for å håndtere opplasting av filer, Dropzone og fil-visning ([#2504](https://github.com/navikt/aksel/pull/2504))
+2 -1
View File
@@ -4,8 +4,9 @@ ENV NODE_ENV production
WORKDIR /app
COPY aksel.nav.no/website/.next/standalone ./
COPY aksel.nav.no/website/config/next-logger.config.js ./
COPY aksel.nav.no/website/next-logger.config.js ./
ENV NODE_ENV=production
ENV NODE_OPTIONS '-r next-logger'
EXPOSE 3000
+3
View File
@@ -21,6 +21,9 @@ spec:
outbound:
external:
- host: api.sanity.io
- host: api.slack.com
- host: slack.com
- host: wss-primary.slack.com
- host: hnbe3yhs.api.sanity.io
- host: apicdn.sanity.io
- host: hnbe3yhs.apicdn.sanity.io
+3
View File
@@ -25,6 +25,9 @@ spec:
outbound:
external:
- host: api.sanity.io
- host: api.slack.com
- host: slack.com
- host: wss-primary.slack.com
- host: hnbe3yhs.api.sanity.io
- host: apicdn.sanity.io
- host: hnbe3yhs.apicdn.sanity.io
+5 -5
View File
@@ -15,10 +15,10 @@
"playroom"
],
"dependencies": {
"@navikt/aksel-icons": "^6.3.0",
"@navikt/ds-css": "^6.3.0",
"@navikt/ds-react": "^6.3.0",
"@navikt/ds-tailwind": "^6.3.0",
"@navikt/ds-tokens": "^6.3.0"
"@navikt/aksel-icons": "^6.3.4",
"@navikt/ds-css": "^6.3.4",
"@navikt/ds-react": "^6.3.4",
"@navikt/ds-tailwind": "^6.3.4",
"@navikt/ds-tokens": "^6.3.4"
}
}
+1 -1
View File
@@ -11,7 +11,7 @@
"babel-loader": "^9.1.3",
"babel-plugin-preval": "^5.1.0",
"css-loader": "^6.8.1",
"playroom": "^0.32.1",
"playroom": "^0.37.0",
"style-loader": "^3.3.3"
},
"dependencies": {
+1 -1
View File
@@ -13,7 +13,7 @@ module.exports = {
title: "Aksel Sandbox",
snippets: path.resolve("./src/snippets.ts"),
widths: [320, 480, 768, 1024, 1280, 1440],
defaultWidths: [320, 1280], // will be supported if this PR is merged: https://github.com/seek-oss/playroom/pull/302
defaultVisibleWidths: [320, 1280],
port: 9000,
frameComponent: path.resolve("./src/FrameComponent.tsx"),
useScope: path.resolve("./src/useScope.tsx"),
+8
View File
@@ -99,6 +99,14 @@ const config: StorybookConfig = {
__dirname,
"../components/layout/",
);
sbConfig.resolve.alias["@/auth"] = resolve(
__dirname,
"../components/auth/",
);
sbConfig.resolve.alias["@/slack"] = resolve(
__dirname,
"../components/utils/slack/index.ts",
);
sbConfig.resolve.alias["@/assets"] = resolve(
__dirname,
"../components/assets/",
@@ -0,0 +1,32 @@
import { NextApiRequest, NextApiResponse } from "next/types";
export type ApiHandler = (
req: NextApiRequest,
res: NextApiResponse,
) => Promise<unknown> | unknown;
export type AuthUser = {
name: "name";
email: "email";
};
export const AuthApiErrorReturn = {
"Access denied": "Access denied",
} as const;
export type AuthApiErrorReturn = {
ok: false;
error: (typeof AuthApiErrorReturn)[keyof typeof AuthApiErrorReturn];
};
type LoggedInT = {
signedIn: true;
user: AuthUser;
};
type NotLoggedInT = {
signedIn: false;
user: null;
};
export type UserStateT = LoggedInT | NotLoggedInT;
@@ -1,11 +1,6 @@
import { NextApiRequest, NextApiResponse } from "next/types";
import { logger } from "@navikt/next-logger";
import { ApiHandler } from "@/auth/auth.types";
import { validateWonderwallToken } from "@/auth/validateWonderwall";
import { logger } from "../../config/logger";
type ApiHandler = (
req: NextApiRequest,
res: NextApiResponse,
) => Promise<unknown> | unknown;
/**
* Used to authenticate Next.JS pages. Assumes application is behind
@@ -1,15 +1,13 @@
import { NextApiRequest } from "next/types";
import { logger } from "@navikt/next-logger";
import { AuthUser } from "@/auth/auth.types";
export function getAuthUser(headers: NextApiRequest["headers"]): {
name: string | null;
email: string | null;
} {
export function getAuthUser(
headers: NextApiRequest["headers"],
): AuthUser | null {
const token = getToken(headers);
if (!token) {
return {
name: null,
email: null,
};
return null;
}
const jwt = JSON.parse(
@@ -26,7 +24,12 @@ export const fakeToken =
"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiIsImtpZCI6IjlHbW55RlBraGMzaE91UjIybXZTdmduTG83WSJ9.eyJhdWQiOiJmYWtlLWF1ZCIsImlzcyI6Imh0dHBzOi8vbG9naW4ubWljcm9zb2Z0b25saW5lLmNvbS85NjZhYzU3Mi1mNWI3LTRiYmUtYWE4OC1jNzY0MTljMGY4NTEvdjIuMCIsImlhdCI6MTY5ODQxMTU0NiwibmJmIjoxNjk4NDExNTQ2LCJleHAiOjE2OTg0MTY1MjYsImFpbyI6ImZha2UtYWlvIiwiYXpwIjoiZmFrZS1henAiLCJhenBhY3IiOiIyIiwiZ3JvdXBzIjpbImZha2UtZ3JvdXAiXSwibmFtZSI6IkZha2UgVG9rZW5lbnNzb24iLCJvaWQiOiJmYWtlLW9pZCIsInByZWZlcnJlZF91c2VybmFtZSI6ImZha2UtbG9uZy1lbWFpbC1hdEBleGFtcGxlLm5vIiwicmgiOiJmYWtlLXJoLiIsInNjcCI6ImRlZmF1bHRhY2Nlc3MiLCJzdWIiOiJmYWtlLXN1YiIsInRpZCI6ImZha2UtdGlkIiwidXRpIjoiekY4R0VGemhfRTZya3gtYzNnNFNBQSIsInZlciI6IjIuMCIsIk5BVmlkZW50IjoiWjk5MjM4OSIsImF6cF9uYW1lIjoiZGV2LWdjcDpmYWtlLXRlYW06aGVsc2VzamVray1ib3QifQ.OOP_Q0ZcUI6jQzM1UON9mA8d8BU_GhioQ5OLuepwebg";
export function getToken(headers: NextApiRequest["headers"]) {
if (process.env.NODE_ENV !== "production") return fakeToken;
if (process.env.NODE_ENV !== "production") {
logger.info(
"Is running locally or in demo, using local fake token for Authenticated user-requests",
);
return fakeToken;
}
return headers.authorization?.replace("Bearer ", "") ?? null;
}
@@ -0,0 +1,35 @@
import { NextApiRequest } from "next/types";
import { logger } from "@navikt/next-logger";
import { UserStateT } from "@/auth/auth.types";
import { getAuthUser } from "@/auth/getAuthUser";
import { validateWonderwallToken } from "@/auth/validateWonderwall";
export async function getAuthUserState(
headers: NextApiRequest["headers"],
): Promise<UserStateT> {
const isSignedIn = await validateWonderwallToken(headers);
if (!isSignedIn) {
return {
signedIn: false,
user: null,
};
}
const user = getAuthUser(headers);
if (!user) {
logger.error(
"User is signed in, but no user was found in the token. This should be impossible!",
);
return {
signedIn: false,
user: null,
};
}
return {
signedIn: true,
user,
};
}
@@ -1,16 +1,22 @@
import { useRouter } from "next/router";
export const useAuth = () => {
const router = useRouter();
export const useAuth = (addRedirect?: boolean) => {
const { push, asPath } = useRouter();
const login = async () => {
router.push(`/oauth2/login?redirect=${router.asPath}`, undefined, {
shallow: true,
});
push(
`/oauth2/login?redirect=${asPath.split("#")[0]}${
addRedirect ? encodeURIComponent("#scrollToFeedback") : ""
}`,
undefined,
{
shallow: true,
},
);
};
const logout = async () => {
router.push(`/oauth2/logout?redirect=${router.asPath}`, undefined, {
push(`/oauth2/logout?redirect=${asPath}`, undefined, {
shallow: true,
});
};
@@ -1,6 +1,6 @@
import { NextApiRequest } from "next/types";
import { logger } from "@navikt/next-logger";
import { getToken, validateAzureToken } from "@navikt/oasis";
import { logger } from "../../config/logger";
/**
* Validates the wonderwall token according to nais.io. Should only actually redirect if the token has expired.
@@ -29,6 +29,10 @@ export async function validateWonderwallToken(
{ cause: validationResult.error },
),
);
} else {
logger.error(
`JWT token has expired (cause: ${validationResult.errorType} ${validationResult.error}`,
);
}
return false;
}
@@ -6,4 +6,5 @@ export enum AmplitudeEvents {
"feedbackinteraksjon" = "feedbackinteraksjon",
"søk" = "søk",
"link" = "link",
"slackfeedback" = "slackfeedback",
}
@@ -61,7 +61,7 @@ const useAmplitudeInit = () => {
.forEach(([event, eventData]) => amplitude.track(event, eventData)),
)
.catch(async () => {
const { logger } = await import("../../config/logger");
const { logger } = await import("@navikt/next-logger");
logger.error("Failed logging batched events");
});
};
@@ -41,7 +41,7 @@ export const DtList = ({ prop }: { prop: Prop; parent: string }) => {
return (
<BodyShort as="ul" className="dtlist overflow-x-auto">
{prop.type && (
<li className="my-3 flex flex-col px-3 text-base md:flex-row">
<li className="my-3 flex flex-col break-all px-3 text-base md:flex-row">
<div className="min-w-24 font-semibold">Type: </div>
<code className="mt-05 text-sm">
{Highlighter({ type: prop.type })}
@@ -9,7 +9,7 @@ type PropTableProps = {
const PropTable = ({ komponent }: PropTableProps) => {
return (
<div>
<div lang="en">
<Heading
size="xsmall"
level="3"
@@ -19,14 +19,14 @@ const PropTable = ({ komponent }: PropTableProps) => {
{komponent?.title ? komponent.title : "Props"}
</Heading>
<div className="toc-ignore relative mb-8 break-all">
<div className="toc-ignore relative mb-8">
{komponent?.propref?.proplist?.length === 0 && (
<div className="mb-8 rounded-b-lg border border-gray-300 p-2">
<BodyShort>Fant ingen props for denne komponenten.</BodyShort>
</div>
)}
<dl lang="en">
<dl>
{komponent?.overridable && (
<div className="border border-t-0 border-gray-300 p-2">
<dt className="px-2 py-2">
@@ -22,7 +22,7 @@ describe("Utils test", () => {
expect(capitalize(str4)).toEqual("Teststr");
});
test("dateStr", async () => {
test("dateStr", { retry: 10, timeout: 8000 }, async () => {
const date = "2022-06-09T11:05:48Z";
const date2 = "2021-03-02T12:05:48Z";
@@ -2,7 +2,11 @@ export const dateStr = async (date: string) => {
if (!date) {
return "";
}
const { format } = await import("date-fns");
const { nb } = await import("date-fns/locale");
const [{ format }, { nb }] = await Promise.all([
import("date-fns"),
import("date-fns/locale"),
]);
return format(new Date(date), "d. MMMM yyy", { locale: nb });
};
@@ -0,0 +1,42 @@
import { expect, test } from "vitest";
import { findUserByEmail } from "../find-user";
test("findUserByEmail returns user when user with given email exists", () => {
const members = [
{ id: "test_id", email: "test1@example.com" },
{ id: "test_id", email: "test2@example.com" },
{ id: "test_id", email: "test3@example.com" },
];
const result = findUserByEmail("test2@example.com", members);
expect(result).toEqual(members[1]);
});
test("findUserByEmail returns undefined when user with given email does not exist", () => {
const members = [
{ id: "test_id", email: "test1@example.com" },
{ id: "test_id", email: "test2@example.com" },
{ id: "test_id", email: "test3@example.com" },
];
const result = findUserByEmail("nonexistent@example.com", members);
expect(result).toBeUndefined();
});
test("findUserByEmail returns undefined member contains no email-key", () => {
const members = [{}, {}, {}];
const result = findUserByEmail("nonexistent@example.com", members);
expect(result).toBeUndefined();
});
test("findUserByEmail is case insensitive", () => {
const members = [
{ id: "test_id", email: "test1@example.com" },
{ id: "test_id", email: "test2@example.com" },
{ id: "test_id", email: "test3@example.com" },
];
const result = findUserByEmail("TEST2@EXAMPLE.COM", members);
expect(result).toEqual(members[1]);
});
@@ -0,0 +1,94 @@
import { UsersListResponse, WebClient } from "@slack/web-api";
import NodeCache from "node-cache";
import {
FetchSlackMembersError,
FetchSlackMembersSuccess,
SanitizedUser,
} from "./slack.types";
const cache = new NodeCache();
const CACHE_KEY = "fetchslackMembers";
export async function fetchSlackMembers(): Promise<
FetchSlackMembersSuccess | FetchSlackMembersError
> {
const slackMembers: FetchSlackMembersSuccess["members"] | undefined =
cache.get(CACHE_KEY);
if (slackMembers) {
return {
ok: true,
members: slackMembers,
};
}
const client = new WebClient(process.env.SLACK_BOT_TOKEN);
const pagination: {
limit: number;
cursor: string | undefined;
continue: boolean;
} = {
limit: 900,
cursor: undefined,
continue: true,
};
let members: Exclude<UsersListResponse["members"], undefined> = [];
let error: string | undefined = undefined;
while (pagination.continue) {
const users = await client.users.list({
cursor: pagination.cursor,
limit: pagination.limit,
});
if (users.ok && users.members) {
members = members.concat(users.members);
} else {
pagination.continue = false;
error = users.error;
break;
}
if (
users.response_metadata?.next_cursor &&
users.response_metadata?.next_cursor !== ""
) {
pagination.cursor = users.response_metadata.next_cursor;
} else {
pagination.continue = false;
}
}
if (error) {
return {
ok: false,
error: error ?? "Error when paginating slack-users",
};
}
/**
* Lets filter out som unwanted users to simplify the list
*/
const subset_members = members
.filter((m) => !m.is_bot)
.filter((m) => !m.deleted)
.filter((m) => m.profile?.email)
.filter((m) => m.id)
.map((m) => ({
id: m.id,
email: m.profile?.email,
})) as SanitizedUser[]; // TODO: perhaps not needed with newer TS version
if (subset_members.length === 0) {
return { ok: false, error: "No members found" };
}
cache.set(CACHE_KEY, subset_members, 60 * 60 * 24);
return {
ok: true,
members: subset_members,
};
}
@@ -0,0 +1,8 @@
import { SanitizedUser } from "./slack.types";
export function findUserByEmail(
email: string,
members: Partial<SanitizedUser>[],
) {
return members?.find((m) => m.email?.toLowerCase() === email.toLowerCase());
}
@@ -0,0 +1,7 @@
export { fetchSlackMembers } from "./fetch-members";
export { findUserByEmail } from "./find-user";
export {
SlackFeedbackError,
type SlackFeedbackResponse,
type SlackFeedbackErrorT,
} from "./slack.types";
@@ -0,0 +1,57 @@
import { AuthApiErrorReturn } from "@/auth/auth.types";
export type SlackFeedbackInput = {
/**
* Feedback message
*/
feedback: string;
/**
* If user wants to be anonymous
*/
anon: boolean;
/**
* Sanity-document id for article feedback is about
*/
document_id: string;
};
type SlackFeedbackOkResponse = {
ok: true;
};
export const SlackFeedbackError = {
InvalidBody: "InvalidBody",
InvalidUser: "InvalidUser",
InvalidId: "InvalidId",
NoEditors: "NoEditors",
NoSlackUsers: "NoSlackUsers",
PostMessageError: "PostMessageError",
} as const;
export type SlackFeedbackErrorT =
(typeof SlackFeedbackError)[keyof typeof SlackFeedbackError];
type SlackFeedbackErrorResponse = {
ok: false;
error: SlackFeedbackErrorT;
};
export type SlackFeedbackResponse =
| SlackFeedbackOkResponse
| SlackFeedbackErrorResponse
| AuthApiErrorReturn;
export type SanitizedUser = {
id: string;
email: string;
};
export type FetchSlackMembersSuccess = {
ok: true;
members: SanitizedUser[];
};
export type FetchSlackMembersError = {
ok: false;
error: string;
};
@@ -0,0 +1,22 @@
.responsive {
font-size: 4rem;
}
.responsive_square {
position: absolute;
transform: translate(1rem, -1.1rem);
}
.responsive_inbox {
position: absolute;
}
@media (max-width: 768px) {
.responsive {
font-size: 3rem;
}
.responsive_square {
transform: translate(0.95rem, -1rem);
}
}
@@ -0,0 +1,53 @@
import type { Meta, StoryObj } from "@storybook/react";
import { userEvent, within } from "@storybook/test";
import { AkselTheme } from "@/sb-util";
import { Feedback, FeedbackForm } from "./Feedback";
const meta = {
title: "Layout/god-praksis/Feedback",
component: Feedback,
tags: ["autodocs"],
decorators: [AkselTheme],
} satisfies Meta<typeof Feedback>;
export default meta;
type Story = StoryObj<typeof meta>;
export const WithOutName: Story = {
args: { userState: { signedIn: false, user: null } },
};
export const WithName: Story = {
args: {
userState: {
signedIn: true,
user: {
email: "email",
name: "name",
},
},
},
};
export const InteractionStates = {
render: () => {
return <FeedbackForm user={{ email: "email", name: "name" }} />;
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const input = canvas.getByLabelText("Innspill");
await userEvent.click(input);
await userEvent.type(input, "e".repeat(500) + "!");
const submit = canvas.getByText("Send inn");
await userEvent.click(submit);
await new Promise((resolve) => setTimeout(resolve, 1000));
await userEvent.click(input);
await userEvent.keyboard("{backspace}");
await userEvent.click(submit);
},
};
@@ -0,0 +1,262 @@
import React, { useState } from "react";
import { InboxDownIcon, PersonIcon } from "@navikt/aksel-icons";
import {
Alert,
BodyLong,
BodyShort,
Box,
Button,
HGrid,
HStack,
Heading,
Link,
Textarea,
VStack,
} from "@navikt/ds-react";
import { AuthUser, UserStateT } from "@/auth/auth.types";
import { useAuth } from "@/auth/useAuth";
import { useSanityData } from "@/hooks/useSanityData";
import { AmplitudeEvents, amplitude } from "@/logging";
import { SlackFeedbackResponse } from "@/slack";
import styles from "./Feedback.module.css";
type States = "feedbackSent" | "loggedIn" | "error" | "submittingForm";
export const FeedbackForm = ({ user }: { user: AuthUser }) => {
const [state, setState] = useState<States>("loggedIn");
const [APIError, setAPIError] = useState<null | string>(null);
const sanityDocumentId = useSanityData()?.id;
const { logout } = useAuth();
const [formError, setFormError] = useState<string | null>(null);
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setState("loggedIn");
const formData = new FormData(event.currentTarget);
const feedback = (formData.get("feedback") as string) || "";
if (!feedback) {
setFormError("Feltet kan ikke være tomt.");
return;
}
if (feedback.length > 500) {
setFormError("Tilbakemeldingen må være under 500 tegn.");
return;
}
const body = JSON.stringify({
feedback: feedback.slice(0, 500) || "",
document_id: sanityDocumentId,
});
setState("submittingForm");
fetch("/api/slack/feedback/v1", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body,
})
.then((res) => res.json())
.then((res: SlackFeedbackResponse) => {
const feedbackMetadata = {
side: window.location.pathname,
length: feedback.length,
};
if (!res.ok) {
setAPIError(res.error);
setState("error");
amplitude.track(AmplitudeEvents.slackfeedback, {
result: "error",
...feedbackMetadata,
});
} else {
setFormError(null);
setState("feedbackSent");
amplitude.track(AmplitudeEvents.slackfeedback, {
result: "success",
...feedbackMetadata,
});
}
})
.catch(() => {
setAPIError("unknownError");
setState("error");
amplitude.track(AmplitudeEvents.slackfeedback, {
result: "error",
side: window.location.pathname,
length: feedback.length,
});
});
};
if (state === "feedbackSent") {
return (
<div>
<IntroSection
heading="Innspill sendt"
description="Ditt innspill er viktig for å holde kvaliteten oppe og innholdet
relevant. Takk skal du ha!"
/>
<Button
onClick={() => setState("loggedIn")}
className="bg-deepblue-600 hover:bg-deepblue-700 active:bg-deepblue-700"
>
Nytt innspill
</Button>
</div>
);
}
return (
<form onSubmit={handleSubmit}>
<IntroSection
heading="Innspill til artikkelen"
description="Har du innspill til artikkelen? Meldingen blir sendt med Slack til team Aksel og folka som har lagd artikkelen 🙌"
/>
<VStack gap="4">
<HStack gap="2">
<PersonIcon aria-hidden fontSize="1.5rem" />
<BodyShort>{user.name}</BodyShort>
<BodyShort>
(
<Link
onClick={() => {
logout();
}}
href="#"
>
logg ut
</Link>
)
</BodyShort>
</HStack>
<Textarea
name="feedback"
label="Innspill"
minRows={4}
maxLength={500}
error={formError}
onInput={(element) => {
if (element.currentTarget.value.length > 500) {
return;
}
setFormError(null);
}}
/>
</VStack>
<Button
type="submit"
loading={state === "submittingForm"}
className="mt-4 bg-deepblue-600 hover:bg-deepblue-700 active:bg-deepblue-700"
>
Send inn
</Button>
<>
{state === "error" && (
<Alert variant="error" className="mt-4" role="alert">
Noe gikk galt. Hvis feilen oppstår flere ganger eller du har lyst
til å sende tilbakemeldingen direkte finner du oss under{" "}
<Link inlineText href="https://nav-it.slack.com/archives/C7NE7A8UF">
#aksel-designsystemet
</Link>{" "}
på slack.
<BodyShort
textColor="subtle"
size="small"
className="mt-3"
>{`Feil-id: ${APIError}`}</BodyShort>
</Alert>
)}
</>
</form>
);
};
function IntroSection({
heading,
description,
}: {
heading: string;
description: string;
}) {
return (
<>
<Heading
level="2"
tabIndex={-1}
id="scrollToFeedback"
size="small"
className="mb-1 focus:outline-none"
>
{heading}
</Heading>
<BodyLong className="mb-4">{description}</BodyLong>
</>
);
}
type Props = {
userState: UserStateT;
};
export const Feedback = ({ userState }: Props) => {
const { login } = useAuth(true);
return (
<Box
borderRadius="large"
background="surface-neutral-subtle"
className="mt-20"
padding="6"
>
<HGrid columns={{ xs: "1fr 3.5rem", md: "1fr 4.5rem" }} gap="2">
{userState.signedIn ? (
<FeedbackForm user={userState.user} />
) : (
<div>
<IntroSection
heading="Innspill til artikkelen"
description="Logg inn med NAV SSO for å gi innspill til artikkelen"
/>
<Button
onClick={() => login()}
className="bg-deepblue-600 hover:bg-deepblue-700 active:bg-deepblue-700"
>
Logg inn med NAV SSO
</Button>
</div>
)}
<div className="responsive-svg relative translate-x-[-0.2rem] translate-y-[0.7rem]">
<svg
aria-hidden
width="1em"
height="1em"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={`${styles.responsive} ${styles.responsive_square}`}
>
<rect
x="0"
y="0"
width="14"
height="14"
rx="0.75"
transform="translate(12.2 2.5) rotate(45 0 0)"
fill="#D6C3EE"
/>
</svg>
<InboxDownIcon
className={`${styles.responsive} ${styles.responsive_inbox} absolute`}
aria-hidden
fontSize="4rem"
/>
</div>
</HGrid>
</Box>
);
};
-7
View File
@@ -1,7 +0,0 @@
import pino from "pino";
import { logger as sharedPinoConfig } from "./next-logger.config";
/**
* This logger is not isomorphic, and should only be used on the server
*/
export const logger: pino.Logger = sharedPinoConfig();
@@ -1,31 +0,0 @@
const pino = require("pino");
const logger = (defaultConfig = {}) =>
pino({
...defaultConfig,
timestamp: false,
formatters: {
level: (label) => {
return { level: label };
},
log: (object) => {
if (object.err) {
// massage the error object so logs.adeo.no shows stack traces
const err =
object.err instanceof Error
? pino.stdSerializers.err(object.err)
: object.err;
object.stack_trace = err.stack;
object.type = err.type;
object.message = err.message;
delete object.err;
}
return object;
},
},
});
module.exports = {
logger,
};
@@ -0,0 +1,5 @@
const { backendLogger } = require("@navikt/next-logger");
module.exports = {
logger: backendLogger,
};
+6 -2
View File
@@ -32,6 +32,7 @@
},
"dependencies": {
"@amplitude/analytics-browser": "^2.2.3",
"@navikt/next-logger": "1.23.0",
"@navikt/oasis": "3.2.1",
"@portabletext/react": "^3.0.11",
"@sanity/client": "^6.13.3",
@@ -41,6 +42,7 @@
"@sanity/locale-nb-no": "^1.1.0",
"@sanity/table": "1.1.2",
"@sanity/vision": "3.34.0",
"@slack/web-api": "^7.0.2",
"boring-avatars": "1.10.1",
"clsx": "^2.1.0",
"codesandbox": "^2.2.3",
@@ -50,8 +52,9 @@
"lodash": "^4.17.21",
"lz-string": "^1.5.0",
"next": "14.1.1-canary.57",
"next-logger": "3.0.2",
"next-logger": "^3.0.2",
"next-sanity": "5.5.8",
"node-cache": "^5.1.2",
"pino": "^8.14.1",
"prism-react-renderer": "^2.0.0",
"react": "^18.0.0",
@@ -67,7 +70,8 @@
"styled-components": "^6.0.0",
"swr": "^1.1.2",
"tailwindcss": "^3.3.3",
"tinycolor2": "^1.6.0"
"tinycolor2": "^1.6.0",
"zod": "^3.22.4"
},
"devDependencies": {
"@axe-core/playwright": "^4.5.2",
+1
View File
@@ -0,0 +1 @@
export { pinoLoggingRoute as default } from "@navikt/next-logger";
@@ -0,0 +1,326 @@
import { SectionBlock, UsersListResponse, WebClient } from "@slack/web-api";
import type { NextApiRequest, NextApiResponse } from "next";
import { z } from "zod";
import { createChildLogger } from "@navikt/next-logger";
import { authProtectedApi } from "@/auth/authProtectedApi";
import { getAuthUser } from "@/auth/getAuthUser";
import { getClient } from "@/sanity/client.server";
import {
SlackFeedbackError,
SlackFeedbackErrorT,
SlackFeedbackResponse,
fetchSlackMembers,
findUserByEmail,
} from "@/slack";
const maxFeedbackLength = 500;
const requestBodySchema = z.object({
body: z.object({
feedback: z
.string({
required_error: "Message-string is required",
})
.min(1, "Message-string cannot be empty")
.max(
maxFeedbackLength,
`Message-string cannot be longer than ${maxFeedbackLength} characters`,
),
document_id: z.string({
required_error: "Document-id is required",
}),
}),
});
const client = new WebClient(process.env.SLACK_BOT_TOKEN);
export default authProtectedApi(sendSlackbotFeedback);
async function sendSlackbotFeedback(
request: NextApiRequest,
response: NextApiResponse,
) {
const logger = createChildLogger("slackbot-feedback");
logger.info("Received slackbot feedback request");
const user = getAuthUser(request.headers);
/**
* If authenticated user is not found, we return 400.
* This should in theory no be possible since api is behind `authProtectedApi`
*/
if (!user) {
return errorResponse(response, SlackFeedbackError.InvalidUser);
}
/**
* Validate the request with zod before we continue flow
*/
const validation = requestBodySchema.safeParse({
body: request.body,
});
if (validation.success === false) {
return errorResponse(response, SlackFeedbackError.InvalidBody);
}
/**
* Sanity caches requests with cdn, so no need to cache locally
*/
const document = await getClient().fetch(
`*[_id == $id][0]{
"id": _id,
"title": heading,
"editors": contributors[]->email,
"slug": slug.current
}`,
{
id: validation.data.body.document_id,
},
);
/**
* If no document is found, sanity will return `null`
*/
if (!document) {
return errorResponse(response, SlackFeedbackError.InvalidId);
}
const slackMembers = await fetchSlackMembers();
if (slackMembers.ok === false) {
return errorResponse(response, SlackFeedbackError.NoSlackUsers);
}
/**
* We find the sender in list of slack members
* Since everyone in NAV has access to login,
* but might not use slack we can have some cases where no user is found
*/
const senderSlackUser = findUserByEmail(user.email, slackMembers.members);
/**
* We use contributors found on article and find their matching slack profiles
*/
const slackProfileForEditors = document.editors
.filter(Boolean)
.map((email: string) => findUserByEmail(email, slackMembers.members))
.filter(Boolean) as Exclude<UsersListResponse["members"], undefined>;
let postMessageError = false;
await client.chat
.postMessage({
/* ChannelId for #aksel-feedback-bot */
channel: "C06PY34NNJH",
text: `Tilbakemelding: ${validation.data.body.feedback}`,
blocks: [
...slackBlock({
article: {
id: document.id,
slug: document.slug,
title: document.title,
},
feedback: validation.data.body.feedback,
recievers: slackProfileForEditors
.map((x) => x.id)
.filter(Boolean) as string[],
sender: {
email: user.email,
slackId: senderSlackUser?.id,
},
}),
...(slackProfileForEditors.length === 0
? [
{
type: "section",
text: {
type: "mrkdwn",
text: ":exclamation: Denne artikkelen har ingen redaktører. @here",
},
} satisfies SectionBlock,
]
: []),
],
})
.catch((e) => {
postMessageError = true;
logger.error(
`Error when sending slackbot feedback to private channel: ${e}`,
);
});
for (const editor of slackProfileForEditors) {
if (!editor.id) {
continue;
}
await client.chat
.postMessage({
channel: editor.id,
text: `Tilbakemelding: ${validation.data.body.feedback}`,
blocks: slackBlock({
article: {
id: document.id,
slug: document.slug,
title: document.title,
},
feedback: validation.data.body.feedback,
recievers: slackProfileForEditors
.map((x) => x.id)
.filter(Boolean) as string[],
sender: {
email: user.email,
slackId: senderSlackUser?.id,
},
}),
})
.catch((e) => {
postMessageError = true;
logger.error(`Error when sending slackbot feedback: ${e}`);
});
}
if (postMessageError) {
return errorResponse(response, SlackFeedbackError.PostMessageError);
}
response.status(200).json(responseJson(true));
return;
}
const ErrorMap = {
[SlackFeedbackError.InvalidUser]: `Error with getAuthUser in slackbot feedback. This should not happend since we are using authProtectedApi`,
[SlackFeedbackError.InvalidBody]: `Error with validation of body in slackbot feedback`,
[SlackFeedbackError.InvalidId]: `Error when fetching sanity document for slackbot feedback`,
[SlackFeedbackError.NoSlackUsers]: `Error extracting members from slack in slackbot feedback`,
[SlackFeedbackError.PostMessageError]: `Error when sending slackbot feedback to private channel or user in slackbot feedback`,
};
function errorResponse(
response: NextApiResponse,
error: keyof typeof ErrorMap,
) {
const logger = createChildLogger("slackbot-feedback");
logger.error(ErrorMap[error] ?? "unknown error");
response.status(400).json(responseJson(false, error));
}
function responseJson(
valid: boolean,
message?: SlackFeedbackErrorT,
): SlackFeedbackResponse {
if (!valid && message) {
return { ok: false, error: message };
}
return { ok: true };
}
type SlackBlockT = {
feedback: string;
article: { slug: string; title: string; id: string };
recievers: string[];
sender: { email: string; slackId?: string };
};
function slackBlock({ feedback, article, recievers, sender }: SlackBlockT) {
return [
{
type: "header",
text: {
type: "plain_text",
/* TODO: Endre melding basert på om du er editor eller temakontakt */
text: "Innspill til Aksel-artikkel du har medvirket til",
emoji: true,
},
},
{
type: "rich_text",
elements: [
{
type: "rich_text_quote",
elements: [
{
type: "text",
text: `\n${feedback}\n\n`,
},
],
},
],
},
{
type: "divider",
},
{
type: "rich_text",
elements: [
{
type: "rich_text_section",
elements: [
{
type: "text",
text: `Avsender:\n`,
style: {
bold: true,
},
},
{
...(sender.slackId
? { type: "user", user_id: sender.slackId }
: { type: "text", text: sender.email }),
},
{
type: "text",
text: "\n\nArtikkel:\n",
style: {
bold: true,
},
},
{
type: "link",
url: `https://aksel.nav.no/${article.slug}`,
text: `${article.title}\n\n`,
},
{
type: "text",
text: "\n\nMedvirkende (disse fikk tilbakemeldingen):\n",
style: {
bold: true,
},
},
],
},
{
type: "rich_text_list",
style: "bullet",
indent: 0,
border: 0,
elements: recievers.map((reciever) => ({
type: "rich_text_section",
elements: [{ type: "user", user_id: reciever }],
})),
},
],
},
{
type: "divider",
},
{
type: "section",
text: {
type: "mrkdwn",
text: "Rediger artikkel ",
},
accessory: {
type: "button",
text: {
type: "plain_text",
text: ":pencil2: Rediger",
emoji: true,
},
url: `https://aksel.nav.no/admin/prod/intent/edit/id=${article.id}`,
},
},
{
type: "divider",
},
];
}
@@ -13,7 +13,7 @@ const Example = () => {
};
// EXAMPLES DO NOT INCLUDE CONTENT BELOW THIS LINE
export default withDsExample(Example, { variant: "static" });
export default withDsExample(Example);
/* Storybook story */
export const Demo = {
@@ -1,14 +1,21 @@
import { useState } from "react";
import { Radio, RadioGroup } from "@navikt/ds-react";
import { withDsExample } from "@/web/examples/withDsExample";
const Example = () => {
const handleChange = (val: any) => console.log(val);
const [error, setError] = useState(
"Du må velge en aldersgruppe før du kan gå videre.",
);
const handleChange = (val: string) => {
console.log(val);
setError("");
};
return (
<RadioGroup
legend="Velg din aldersgruppe."
onChange={(val: any) => handleChange(val)}
error="Vi fant ingen resultater på din aldersgruppe."
onChange={handleChange}
error={error}
>
<Radio value="10">10-20 år</Radio>
<Radio value="20">21-45 år</Radio>
@@ -3,7 +3,7 @@ import { withDsExample } from "@/web/examples/withDsExample";
const Example = () => {
return (
<form>
<form role="search">
<Search label="Søk alle NAV sine sider" variant="secondary">
<Search.Button type="button" />
</Search>
@@ -17,9 +17,9 @@ export default withDsExample(Example);
/* Storybook story */
export const Demo = {
render: Example,
desc: "Children kan erstatte knapp hvis man trenger eget ikon eller 'type'-prop",
};
export const args = {
index: 8,
desc: "Children kan erstatte knapp hvis man f.eks. trenger eget ikon eller 'type'-prop.",
};
@@ -3,14 +3,14 @@ import { withDsExample } from "@/web/examples/withDsExample";
const Example = () => {
return (
<form data-theme="dark">
<form data-theme="dark" role="search">
<Search label="Søk alle NAV sine sider" variant="secondary" />
</form>
);
};
// EXAMPLES DO NOT INCLUDE CONTENT BELOW THIS LINE
export default withDsExample(Example, { variant: "full" });
export default withDsExample(Example, { variant: "inverted" });
export const args = {
index: 7,
@@ -3,7 +3,7 @@ import { withDsExample } from "@/web/examples/withDsExample";
const Example = () => {
return (
<form>
<form role="search">
<Search
label="Søk alle NAV sine sider"
variant="secondary"
@@ -3,10 +3,9 @@ import { withDsExample } from "@/web/examples/withDsExample";
const Example = () => {
return (
<form>
<form role="search">
<Search
label="Hva er artikkelnummeret til denne varen?"
description="Art.nr. finner du på varens produktside eller i katalogen."
hideLabel={false}
htmlSize="12"
/>
@@ -3,7 +3,7 @@ import { withDsExample } from "@/web/examples/withDsExample";
const Example = () => {
return (
<form>
<form role="search">
<Search label="Søk alle NAV sine sider" variant="primary" />
</form>
);
@@ -18,5 +18,6 @@ export const Demo = {
};
export const args = {
index: 0,
index: 1,
desc: "Primary brukes der søk er hovedfunksjon, f.eks. ved globalt søk. Merk at det som regel bare skal finnes ett primary søk på en og samme side.",
};
@@ -3,7 +3,7 @@ import { withDsExample } from "@/web/examples/withDsExample";
const Example = () => {
return (
<form>
<form role="search">
<Search label="Søk alle NAV sine sider" variant="secondary" />
</form>
);
@@ -18,5 +18,6 @@ export const Demo = {
};
export const args = {
index: 1,
index: 0,
desc: "Hvis du er i tvil om hvilken variant du skal bruke, er det sikkert denne som er riktig.",
};
@@ -3,7 +3,7 @@ import { withDsExample } from "@/web/examples/withDsExample";
const Example = () => {
return (
<form>
<form role="search">
<Search label="Søk alle NAV sine sider" variant="simple" />
</form>
);
@@ -19,4 +19,5 @@ export const Demo = {
export const args = {
index: 2,
desc: "Simple er en forenklet variant av søkefeltet uten søkeknapp. Den brukes særlig på input-felter der innholdet blir oppdatert fortløpende eller ved autocomplete med dropdown.",
};
@@ -3,7 +3,7 @@ import { withDsExample } from "@/web/examples/withDsExample";
const Example = () => {
return (
<form>
<form role="search">
<Search
label="Søk alle NAV sine sider"
variant="secondary"
@@ -23,4 +23,5 @@ export const Demo = {
export const args = {
index: 3,
desc: "Small brukes på interne flater der det er behov for et mer komprimert grensesnitt. Small passer også i tilknytning til en komponent, for eksempel til filtrering av data i en tabell.",
};
@@ -3,9 +3,10 @@ import { withDsExample } from "@/web/examples/withDsExample";
const Example = () => {
return (
<form>
<form role="search">
<Search
label="Søk alle NAV sine sider"
description="Her kan du søke på forskjellige ting, f.eks. søknadsskjemaer."
variant="secondary"
hideLabel={false}
/>
@@ -3,7 +3,8 @@ 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 { validateWonderwallToken } from "@/auth/validateWonderwall";
import { UserStateT } from "@/auth/auth.types";
import { getAuthUserState } from "@/auth/getUserState";
import ArtikkelCard from "@/cms/cards/ArtikkelCard";
import Footer from "@/layout/footer/Footer";
import Header from "@/layout/header/Header";
@@ -25,6 +26,7 @@ import {
} from "@/types";
import { abbrName, dateStr, generateTableOfContents } from "@/utils";
import { BreadCrumbs } from "@/web/BreadCrumbs";
import { Feedback } from "@/web/Feedback/Feedback";
import { SEO } from "@/web/seo/SEO";
import TableOfContents from "@/web/toc/TableOfContents";
import NotFotfund from "../../404";
@@ -36,7 +38,7 @@ type PageProps = NextPageT<{
publishDate: string;
verifiedDate: string;
toc: TableOfContentsT;
signedIn: boolean;
userState: UserStateT;
}>;
export const query = `{
@@ -68,7 +70,7 @@ export const query = `{
export const getServerSideProps: GetServerSideProps = async (
context,
): Promise<PageProps> => {
const signedIn = await validateWonderwallToken(context.req.headers);
const userState = await getAuthUserState(context.req.headers);
const isPreview = context.preview ?? false;
@@ -93,7 +95,7 @@ export const getServerSideProps: GetServerSideProps = async (
content: page?.content,
type: "aksel_artikkel",
}),
signedIn,
userState,
},
notFound: !page && !isPreview,
};
@@ -104,6 +106,7 @@ const Page = ({
publishDate,
verifiedDate,
toc,
userState,
}: PageProps["props"]) => {
if (!data) {
return <NotFotfund />;
@@ -253,6 +256,7 @@ const Page = ({
<BodyShort as="span" className="text-text-subtle">
Publisert: {publishDate}
</BodyShort>
<Feedback userState={userState} />
</div>
</div>
</div>
+4 -4
View File
@@ -29,7 +29,6 @@ import { contributorsAll } from "@/sanity/queries";
import { AkselTemaT, NextPageT } from "@/types";
import { userPrefersReducedMotion } from "@/utils";
import { IntroCards } from "@/web/IntroCards";
import { UxTweaks } from "@/web/Uxtweaks";
import { AkselCubeAnimated } from "@/web/aksel-cube/AkselCube";
import { SEO } from "@/web/seo/SEO";
@@ -296,10 +295,11 @@ const Forside = ({ page, tema, blocks }: PageProps["props"]) => {
</ul>
</VStack>
</Box>
<UxTweaks
href="https://study.uxtweak.com/treetest/onFDOBRUT19G3KNNNa7DD"
{/* Kept commented here in case we want to show future questionnaires from uxtweaks */}
{/* <UxTweaks
href="https://study.uxtweak.com/treetest/..."
length={3}
/>
/> */}
{blocks && (
<Box paddingInline={{ xs: "2", lg: "18" }}>
+1
View File
@@ -25,6 +25,7 @@
"@/preview": ["./components/website-modules/preview/WithPreview.tsx"],
"@/logging": ["./components/logging/index.ts"],
"@/utils": ["./components/utils/index.ts"],
"@/slack": ["./components/utils/slack/index.ts"],
"@/layout/*": ["./components/layout/*"],
"@/assets/*": ["./components/assets/*"],
"@/web/*": ["./components/website-modules/*"],
@@ -12,6 +12,7 @@
"@/preview": ["./components/website-modules/preview/WithPreview.tsx"],
"@/logging": ["./components/logging/index.ts"],
"@/utils": ["./components/utils/index.ts"],
"@/slack": ["./components/utils/slack/index.ts"],
"@/layout/*": ["./components/layout/*"],
"@/assets/*": ["./components/assets/*"],
"@/web/*": ["./components/website-modules/*"],
+805 -278
View File
File diff suppressed because it is too large. Load diff