Merged in feat/SW-3636-storybook-structure (pull request #3309)
feat(SW-3636): Storybook structure * New sections in Storybook sidebar * Group Storybook content files and add token files for spacing, border radius and shadows Approved-by: Joakim Jäderberg
This commit is contained in:
@@ -7,7 +7,7 @@ import { IconName } from '../Icons/iconName'
|
||||
import { Typography } from '../Typography'
|
||||
|
||||
const meta: Meta<typeof Accordion> = {
|
||||
title: 'Components/Accordion',
|
||||
title: 'Core Components/Accordion',
|
||||
component: Accordion,
|
||||
argTypes: {
|
||||
type: {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { AlertTypeEnum } from '@scandic-hotels/common/constants/alert'
|
||||
import { expect, fn } from 'storybook/test'
|
||||
|
||||
const meta: Meta<typeof Alert> = {
|
||||
title: 'Components/Alert',
|
||||
title: 'Core Components/Alert',
|
||||
component: Alert,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Avatar } from '.'
|
||||
import { config } from './variants'
|
||||
|
||||
const meta: Meta<typeof Avatar> = {
|
||||
title: 'Components/Avatar',
|
||||
title: 'Core Components/Avatar',
|
||||
component: Avatar,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
|
||||
@@ -6,7 +6,7 @@ import { BackToTopButton } from '.'
|
||||
import { config as backToTopButtonConfig } from './variants'
|
||||
|
||||
const meta: Meta<typeof BackToTopButton> = {
|
||||
title: 'Components/BackToTopButton',
|
||||
title: 'Patterns/BackToTopButton',
|
||||
component: BackToTopButton,
|
||||
argTypes: {
|
||||
onPress: {
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||
import { Badge } from './Badge.tsx'
|
||||
|
||||
const meta: Meta<typeof Badge> = {
|
||||
title: 'Components/Badge',
|
||||
title: 'Core Components/Badge',
|
||||
component: Badge,
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { fn } from 'storybook/test'
|
||||
import { BookingCodeChip } from './index'
|
||||
|
||||
const meta = {
|
||||
title: 'Components/BookingCodeChip',
|
||||
title: 'Product Components/BookingCodeChip',
|
||||
component: BookingCodeChip,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Button } from './Button'
|
||||
import { config as buttonConfig } from './variants'
|
||||
|
||||
const meta: Meta<typeof Button> = {
|
||||
title: 'Components/Button',
|
||||
title: 'Core Components/Button',
|
||||
component: Button,
|
||||
argTypes: {
|
||||
onPress: {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { MaterialIcon } from '../Icons/MaterialIcon'
|
||||
import { config as typographyConfig } from '../Typography/variants'
|
||||
|
||||
const meta: Meta<typeof ButtonLink> = {
|
||||
title: 'Components/ButtonLink',
|
||||
title: 'Core Components/ButtonLink',
|
||||
component: ButtonLink,
|
||||
argTypes: {
|
||||
onClick: {
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||
import { Card } from './Card.tsx'
|
||||
|
||||
const meta: Meta<typeof Card> = {
|
||||
title: 'Components/Card',
|
||||
title: 'Core Components/Card',
|
||||
component: Card,
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { ChipButton } from './ChipButton.tsx'
|
||||
import { config as chipButtonConfig } from './variants'
|
||||
|
||||
const meta: Meta<typeof ChipButton> = {
|
||||
title: 'Components/Chip/ChipButton',
|
||||
title: 'Core Components/ChipButton',
|
||||
component: ChipButton,
|
||||
argTypes: {
|
||||
variant: {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { MaterialIcon } from '../Icons/MaterialIcon'
|
||||
import { ChipLink } from './ChipLink.tsx'
|
||||
|
||||
const meta: Meta<typeof ChipLink> = {
|
||||
title: 'Components/Chip/ChipLink',
|
||||
title: 'Core Components/ChipLink',
|
||||
component: ChipLink,
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||
import { Divider } from './Divider'
|
||||
|
||||
const meta: Meta<typeof Divider> = {
|
||||
title: 'Components/Divider',
|
||||
title: 'Core Components/Divider',
|
||||
component: Divider,
|
||||
argTypes: {},
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ const facilityMapping: Record<string, FacilityEnum> = Object.fromEntries(
|
||||
const colorOptions = Object.keys(iconVariantConfig.variants.color)
|
||||
|
||||
const meta: Meta<typeof FacilityToIcon> = {
|
||||
title: 'Components/Facility To Icon',
|
||||
title: 'Core Components/Facility To Icon',
|
||||
component: FacilityToIcon,
|
||||
argTypes: {
|
||||
id: {
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||
|
||||
import Checkbox from './index'
|
||||
import { FormDecorator } from '../../../../.storybook/decorators/FormDecorator'
|
||||
|
||||
const meta: Meta<typeof Checkbox> = {
|
||||
title: 'Components/Form/Checkbox',
|
||||
component: Checkbox,
|
||||
decorators: [FormDecorator],
|
||||
args: { name: 'checkbox' },
|
||||
}
|
||||
export default meta
|
||||
|
||||
type Story = StoryObj<typeof Checkbox>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {},
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { PaymentMethodEnum } from '@scandic-hotels/common/constants/paymentMetho
|
||||
import { FormDecorator } from '../../../../.storybook/decorators/FormDecorator'
|
||||
|
||||
const meta: Meta<typeof PaymentOptionsGroup> = {
|
||||
title: 'Components/Payment/PaymentOptionsGroup',
|
||||
title: 'Patterns/Form/Payment/PaymentOptionsGroup',
|
||||
component: PaymentOptionsGroup,
|
||||
decorators: [FormDecorator],
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { PaymentMethodEnum } from '@scandic-hotels/common/constants/paymentMetho
|
||||
import { FormDecorator } from '../../../../.storybook/decorators/FormDecorator'
|
||||
|
||||
const meta: Meta<typeof SelectPaymentMethod> = {
|
||||
title: 'Components/Payment/SelectCreditCard',
|
||||
title: 'Patterns/Form/Payment/SelectCreditCard',
|
||||
component: SelectPaymentMethod,
|
||||
argTypes: {},
|
||||
decorators: [FormDecorator],
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Button } from '../Button'
|
||||
import { MaterialIcon } from '../Icons/MaterialIcon'
|
||||
|
||||
const meta: Meta<typeof HotelCard> = {
|
||||
title: 'Components/HotelCard',
|
||||
title: 'Product Components/HotelCard/HotelCard',
|
||||
component: HotelCard,
|
||||
argTypes: {
|
||||
state: {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { fn } from 'storybook/test'
|
||||
import { hotelPins } from '../../../Map/InteractiveMap/storybookData'
|
||||
|
||||
const meta: Meta<typeof StandaloneHotelCardDialog> = {
|
||||
title: 'Components/StandaloneHotelCardDialog',
|
||||
title: 'Product Components/HotelCard/StandaloneHotelCardDialog',
|
||||
component: StandaloneHotelCardDialog,
|
||||
argTypes: {},
|
||||
}
|
||||
|
||||
@@ -6,8 +6,9 @@ import { fn } from 'storybook/test'
|
||||
import { Button } from '../Button'
|
||||
import { MaterialIcon } from '../Icons/MaterialIcon'
|
||||
import { HotelInfoCard } from './index'
|
||||
|
||||
const meta: Meta<typeof HotelInfoCard> = {
|
||||
title: 'Components/HotelInfoCard',
|
||||
title: 'Product Components/HotelInfoCard',
|
||||
component: HotelInfoCard,
|
||||
argTypes: {},
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { IconButton } from './IconButton'
|
||||
import { config } from './variants'
|
||||
|
||||
const meta: Meta<typeof IconButton> = {
|
||||
title: 'Components/IconButton',
|
||||
title: 'Core Components/IconButton',
|
||||
component: IconButton,
|
||||
argTypes: {
|
||||
onPress: {
|
||||
|
||||
@@ -7,7 +7,8 @@ import { HTMLAttributes } from 'react'
|
||||
import { getIconAriaProps } from '../utils'
|
||||
|
||||
export interface MaterialIconProps
|
||||
extends Pick<MaterialSymbolProps, 'size' | 'icon' | 'className' | 'style'>,
|
||||
extends
|
||||
Pick<MaterialSymbolProps, 'size' | 'icon' | 'className' | 'style'>,
|
||||
Omit<HTMLAttributes<HTMLSpanElement>, 'color' | 'id'>,
|
||||
VariantProps<typeof iconVariants> {
|
||||
isFilled?: boolean
|
||||
|
||||
@@ -4,7 +4,7 @@ import { InfoBox, Props } from './InfoBox'
|
||||
import { IconName } from '../Icons/iconName'
|
||||
|
||||
const meta: Meta<typeof InfoBox> = {
|
||||
title: 'Components/InfoBox',
|
||||
title: 'Core Components/InfoBox',
|
||||
component: InfoBox,
|
||||
parameters: {
|
||||
layout: 'padded',
|
||||
|
||||
@@ -20,7 +20,7 @@ const DEFAULT_ARGS = {
|
||||
}
|
||||
|
||||
const meta: Meta<typeof InfoCard> = {
|
||||
title: 'Components/InfoCard',
|
||||
title: 'Product Components/InfoCard',
|
||||
component: InfoCard,
|
||||
argTypes: {
|
||||
topTitle: {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Input } from './Input'
|
||||
import { TextField } from 'react-aria-components'
|
||||
|
||||
const meta: Meta<typeof Input> = {
|
||||
title: 'Components/Input',
|
||||
title: 'Core Components/Input',
|
||||
// @ts-expect-error Input does not support this, but wrapping <TextField> does
|
||||
component: ({ isInvalid, ...props }) => (
|
||||
<TextField isInvalid={isInvalid}>
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||
import { InputLabel } from './InputLabel'
|
||||
|
||||
const meta: Meta<typeof InputLabel> = {
|
||||
title: 'Components/InputLabel',
|
||||
title: 'Core Components/InputLabel',
|
||||
component: InputLabel,
|
||||
argTypes: {},
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { TextField } from 'react-aria-components'
|
||||
import { MaterialIcon } from '../Icons/MaterialIcon'
|
||||
|
||||
const meta: Meta<typeof Input> = {
|
||||
title: 'Components/Input (New)',
|
||||
title: 'Core Components/Input (New)',
|
||||
// @ts-expect-error Input does not support this, but wrapping <TextField> does
|
||||
component: ({ isInvalid, validationState, ...props }) => (
|
||||
<TextField isInvalid={isInvalid} data-validation-state={validationState}>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { RTETypeEnum } from './types/rte/enums'
|
||||
import { RTEImageVaultNode, RTENode } from './types/rte/node'
|
||||
|
||||
const meta: Meta<typeof JsonToHtml> = {
|
||||
title: 'Components/JsonToHtml',
|
||||
title: 'Core Components/JsonToHtml',
|
||||
component: JsonToHtml,
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { IconName } from '../Icons/iconName'
|
||||
import type { LinkListItemProps } from './LinkListItem'
|
||||
|
||||
const meta: Meta<typeof LinkList> = {
|
||||
title: 'Components/LinkList',
|
||||
title: 'Core Components/LinkList/LinkList',
|
||||
component: LinkList,
|
||||
argTypes: {},
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { LinkListItem } from './index'
|
||||
import { IconName } from '../../Icons/iconName'
|
||||
|
||||
const meta: Meta<typeof LinkListItem> = {
|
||||
title: 'Components/LinkListItem',
|
||||
title: 'Core Components/LinkList/LinkListItem',
|
||||
component: LinkListItem,
|
||||
argTypes: {
|
||||
isExternal: {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Loading } from './Loading'
|
||||
import { config } from './variants'
|
||||
|
||||
const meta: Meta<typeof Loading> = {
|
||||
title: 'Components/Loading',
|
||||
title: 'Patterns/Loading',
|
||||
component: Loading,
|
||||
argTypes: {
|
||||
type: {
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||
import { LoadingSpinner } from './index'
|
||||
|
||||
const meta: Meta<typeof LoadingSpinner> = {
|
||||
title: 'Components/LoadingSpinner',
|
||||
title: 'Patterns/LoadingSpinner',
|
||||
component: LoadingSpinner,
|
||||
argTypes: {
|
||||
fullPage: {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { InteractiveMap } from '.'
|
||||
import { hotelPins } from './storybookData'
|
||||
|
||||
const meta: Meta<typeof InteractiveMap> = {
|
||||
title: 'Components/Map/Interactive Map',
|
||||
title: 'Patterns/Map/Interactive Map',
|
||||
component: InteractiveMap,
|
||||
argTypes: {},
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { SignatureHotelEnum } from '@scandic-hotels/common/constants/signatureHo
|
||||
import { Typography } from '../../Typography'
|
||||
|
||||
const meta: Meta<typeof HotelMarkerByType> = {
|
||||
title: 'Components/Map/Hotel Marker By Type',
|
||||
title: 'Patterns/Map/Hotel Marker By Type',
|
||||
component: HotelMarkerByType,
|
||||
argTypes: {
|
||||
hotelType: {
|
||||
|
||||
@@ -5,7 +5,7 @@ type MessageBannerType = 'default' | 'error' | 'info'
|
||||
type TextColor = 'default' | 'error'
|
||||
|
||||
const meta: Meta<typeof MessageBanner> = {
|
||||
title: 'Components/MessageBanner',
|
||||
title: 'Core Components/MessageBanner',
|
||||
component: MessageBanner,
|
||||
argTypes: {
|
||||
type: {
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||
|
||||
import { expect } from 'storybook/test'
|
||||
import OldDSLink from '.'
|
||||
|
||||
const meta: Meta<typeof OldDSLink> = {
|
||||
title: 'Components/OldDSLink',
|
||||
component: OldDSLink,
|
||||
argTypes: {
|
||||
size: {
|
||||
control: 'select',
|
||||
options: ['small', 'regular', 'tiny', 'none'],
|
||||
},
|
||||
scroll: {
|
||||
table: {
|
||||
disable: true,
|
||||
},
|
||||
},
|
||||
prefetch: {
|
||||
table: {
|
||||
disable: true,
|
||||
},
|
||||
},
|
||||
partialMatch: {
|
||||
table: {
|
||||
disable: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export default meta
|
||||
|
||||
type Story = StoryObj<typeof OldDSLink>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
active: false,
|
||||
href: 'https://www.scandichotels.com/en',
|
||||
},
|
||||
render: (args) => <OldDSLink {...args}>{args.href}</OldDSLink>,
|
||||
play: async ({ canvasElement }) => {
|
||||
const link = canvasElement.querySelector('a')
|
||||
if (!link) throw new Error('Link not found')
|
||||
expect(link).toBeInTheDocument()
|
||||
},
|
||||
}
|
||||
|
||||
export const Focused: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
},
|
||||
render: Default.render,
|
||||
play: async ({ canvasElement }) => {
|
||||
const link = canvasElement.querySelector('a')
|
||||
if (!link) throw new Error('Link not found')
|
||||
expect(link).toBeInTheDocument()
|
||||
|
||||
expect(link).not.toHaveFocus()
|
||||
let styles = getComputedStyle(link)
|
||||
expect(styles.outlineStyle).toBe('none')
|
||||
expect(parseFloat(styles.outlineWidth)).toBe(0)
|
||||
|
||||
link?.focus()
|
||||
|
||||
expect(link).toHaveFocus()
|
||||
styles = getComputedStyle(link)
|
||||
expect(styles.outlineStyle).not.toBe('none')
|
||||
expect(parseFloat(styles.outlineWidth)).toBeGreaterThan(0)
|
||||
},
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import { expect } from 'storybook/test'
|
||||
const methods = Object.values(PaymentMethodEnum).toSorted()
|
||||
|
||||
const meta: Meta<typeof PaymentMethodIcon> = {
|
||||
title: 'Components/Payment/PaymentMethodIcon',
|
||||
title: 'Product Components/Payment/PaymentMethodIcon',
|
||||
component: PaymentMethodIcon,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||
import { Progress } from './index'
|
||||
|
||||
const meta: Meta<typeof Progress> = {
|
||||
title: 'Components/Progress',
|
||||
title: 'Core Components/Progress',
|
||||
component: Progress,
|
||||
parameters: {
|
||||
backgrounds: { disable: true },
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||
import CampaignRateCard from '.'
|
||||
|
||||
const meta: Meta<typeof CampaignRateCard> = {
|
||||
title: 'Components/RateCard/Campaign',
|
||||
title: 'Product Components/RateCard/Campaign',
|
||||
component: CampaignRateCard,
|
||||
decorators: [
|
||||
(Story) => (
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||
import CodeRateCard from '.'
|
||||
|
||||
const meta: Meta<typeof CodeRateCard> = {
|
||||
title: 'Components/RateCard/Code',
|
||||
title: 'Product Components/RateCard/Code',
|
||||
component: CodeRateCard,
|
||||
decorators: [
|
||||
(Story) => (
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||
import NoRateAvailableCard from '.'
|
||||
|
||||
const meta: Meta<typeof NoRateAvailableCard> = {
|
||||
title: 'Components/RateCard/NoRateAvailable',
|
||||
title: 'Product Components/RateCard/NoRateAvailable',
|
||||
component: NoRateAvailableCard,
|
||||
decorators: [
|
||||
(Story) => (
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||
import PointsRateCard from '.'
|
||||
|
||||
const meta: Meta<typeof PointsRateCard> = {
|
||||
title: 'Components/RateCard/Points',
|
||||
title: 'Product Components/RateCard/Points',
|
||||
component: PointsRateCard,
|
||||
decorators: [
|
||||
(Story) => (
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||
import RegularRateCard from '.'
|
||||
|
||||
const meta: Meta<typeof RegularRateCard> = {
|
||||
title: 'Components/RateCard/Regular',
|
||||
title: 'Product Components/RateCard/Regular',
|
||||
component: RegularRateCard,
|
||||
decorators: [
|
||||
(Story) => (
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||
import { Select } from './Select'
|
||||
|
||||
const meta: Meta<typeof Select> = {
|
||||
title: 'Components/Select',
|
||||
title: 'Core Components/Select',
|
||||
component: Select,
|
||||
argTypes: {},
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Typography } from '../Typography'
|
||||
import { config } from './variants'
|
||||
|
||||
const meta: Meta<typeof TextLink> = {
|
||||
title: 'Components/TextLink',
|
||||
title: 'Core Components/TextLink',
|
||||
component: TextLink,
|
||||
argTypes: {
|
||||
theme: {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { expect } from 'storybook/test'
|
||||
import { config } from './variants.ts'
|
||||
|
||||
const meta: Meta<typeof Toast> = {
|
||||
title: 'Components/Toasts/Toast',
|
||||
title: 'Core Components/Toast/Toast',
|
||||
component: Toast,
|
||||
argTypes: {
|
||||
variant: {
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Button } from '../Button/Button.tsx'
|
||||
import { expect, waitFor } from 'storybook/test'
|
||||
|
||||
const meta: Meta<typeof Toast> = {
|
||||
title: 'Components/Toasts/ToastHandler',
|
||||
title: 'Core Components/Toast/ToastHandler',
|
||||
component: Toast,
|
||||
argTypes: {
|
||||
variant: {
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||
import { TripAdvisorChip } from './index'
|
||||
|
||||
const meta: Meta<typeof TripAdvisorChip> = {
|
||||
title: 'Components/TripAdvisorChip',
|
||||
title: 'Product Components/TripAdvisorChip',
|
||||
component: TripAdvisorChip,
|
||||
argTypes: {
|
||||
rating: {
|
||||
|
||||
@@ -7,7 +7,7 @@ import TypographyDocs from './Typography.docs.mdx'
|
||||
import { config as typographyConfig } from './variants'
|
||||
|
||||
const meta: Meta<typeof Typography> = {
|
||||
title: 'Components/Typography',
|
||||
title: 'Core Components/Typography',
|
||||
component: Typography,
|
||||
args: { variant: typographyConfig.defaultVariants.variant },
|
||||
argTypes: {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { VideoPlayer } from '.'
|
||||
import { config as videoPlayerConfig } from './variants'
|
||||
|
||||
const meta: Meta<typeof VideoPlayer> = {
|
||||
title: 'Components/🚧 VideoPlayer 🚧',
|
||||
title: 'Core Components/🚧 VideoPlayer 🚧',
|
||||
component: VideoPlayer,
|
||||
|
||||
parameters: {
|
||||
|
||||
Reference in New Issue
Block a user