Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions frontend/documentation/components/CopyField.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,19 @@ export const Default: Story = {
),
}

// The label lives inside CopyField so htmlFor can reach the inner input.
export const WithTitle: Story = {
render: () => (
<Container>
<CopyField
title='SCIM bearer token'
value='eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJzY2ltLXRva2VuIn0'
className='font-monospace'
/>
</Container>
),
}

export const Monospace: Story = {
render: () => (
<Container>
Expand Down
79 changes: 79 additions & 0 deletions frontend/documentation/components/Field.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import React from 'react'
import type { Meta, StoryObj } from 'storybook'

import Field from 'components/base/forms/Field'
import Input from 'components/base/forms/Input'

const meta: Meta<typeof Field> = {
component: Field,
decorators: [
(Story: React.FC) => (
<div style={{ width: 320 }}>
<Story />
</div>
),
],
parameters: {
docs: {
description: {
component:
'The field skeleton: wrapper, label, control, error. Layout only; pass `htmlFor` (and put the matching `id` on the control) to wire the label, or omit it as a visible statement that the label is decorative.',
},
},
layout: 'centered',
},
title: 'Components/Forms/Field',
}
export default meta

type Story = StoryObj<typeof Field>

// Declaring htmlFor once wires everything: Input adopts it as its id via
// FieldContext, and picks up aria-invalid/aria-describedby when there is
// an error.
export const Default: Story = {
render: () => (
<Field title='Email' htmlFor='field-email'>
<Input placeholder='you@example.com' />
</Field>
),
}

export const WithTooltip: Story = {
render: () => (
<Field
title='Email'
tooltip='We never share your email.'
htmlFor='field-email-tooltip'
>
<Input placeholder='you@example.com' />
</Field>
),
}

export const WithError: Story = {
render: () => (
<Field
title='Email'
htmlFor='field-email-error'
error='Enter a valid email address.'
>
<Input isValid={false} autoValidate value='not-an-email' />
</Field>
),
}

export const CustomControl: Story = {
render: () => (
<Field title='Colour' tooltip='No htmlFor: the swatches take no id.'>
<div className='d-flex gap-2'>
{['#5D6D7E', '#27AB95', '#F7D354'].map((c) => (
<div
key={c}
style={{ background: c, borderRadius: 4, height: 24, width: 24 }}
/>
))}
</div>
</Field>
),
}
12 changes: 4 additions & 8 deletions frontend/web/components/AdminAPIKeys.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import React, { PureComponent } from 'react'
import { close as closeIcon } from 'ionicons/icons'
import { IonIcon } from '@ionic/react'
import data from 'common/data/base/_data'
import AppActions from 'common/dispatcher/app-actions'
import OrganisationStore from 'common/stores/organisation-store'
Expand Down Expand Up @@ -226,11 +224,8 @@ export class CreateAPIKey extends PureComponent {
className='chip'
>
<span className='font-weight-bold'>{r.name}</span>
<span className='chip-icon ion'>
<IonIcon
icon={closeIcon}
style={{ fontSize: '13px' }}
/>
<span className='chip-icon'>
<Icon name='close' width={18} />
</span>
</Row>
))}
Expand Down Expand Up @@ -265,9 +260,10 @@ export class CreateAPIKey extends PureComponent {
</>
<Flex>
<div>
<label>Expiry</label>
<label htmlFor='api-key-expiry'>Expiry</label>
</div>
<DateSelect
id='api-key-expiry'
onChange={(e) => {
this.setState({
expiry_date: e?.toISOString(),
Expand Down
48 changes: 29 additions & 19 deletions frontend/web/components/CopyField.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React, { FC } from 'react'
import React, { FC, ReactNode, useId } from 'react'
import Button from './base/forms/Button'
import FieldLabel from './base/forms/FieldLabel'
import Flex from './base/grid/Flex'
import Icon from './icons/Icon'
import Input from './base/forms/Input'
Expand All @@ -13,36 +14,45 @@ import Utils from 'common/utils/utils'
// fit every existing consumer.
type CopyFieldProps = {
value: string
// Optional label, wired to the read-only input; the label must live here
// because callers cannot reach the inner input's id for htmlFor.
title?: ReactNode
className?: string
'data-test'?: string
}

const CopyField: FC<CopyFieldProps> = ({
className,
'data-test': dataTest,
title,
value,
}) => {
const id = useId()
const onCopy = () => Utils.copyToClipboard(value)

return (
<Row className='gap-2 align-items-center'>
<Flex>
<Input
value={value}
readOnly
className={className}
data-test={dataTest}
/>
</Flex>
<Button
theme='secondary'
className='btn-with-icon'
onClick={onCopy}
aria-label='Copy to clipboard'
>
<Icon name='copy' width={20} />
</Button>
</Row>
<>
{!!title && <FieldLabel htmlFor={id}>{title}</FieldLabel>}
<Row className='gap-2 align-items-center'>
<Flex>
<Input
id={id}
value={value}
readOnly
className={className}
data-test={dataTest}
/>
</Flex>
<Button
theme='secondary'
className='btn-with-icon'
onClick={onCopy}
aria-label='Copy to clipboard'
>
<Icon name='copy' width={20} />
</Button>
</Row>
</>
)
}

Expand Down
4 changes: 4 additions & 0 deletions frontend/web/components/DateSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ export interface DateSelectProps
extends Pick<DatePickerProps, 'dateFormat' | 'selected'> {
value?: DatePickerProps['value']
className?: string
// Forwarded to the underlying input so a label's htmlFor can reach it.
id?: string
isValid?: boolean
onChange?: (
date: Date | null,
Expand All @@ -16,6 +18,7 @@ export interface DateSelectProps
const DateSelect: FC<DateSelectProps> = ({
className,
dateFormat,
id,
isValid,
onChange,
selected,
Expand All @@ -29,6 +32,7 @@ const DateSelect: FC<DateSelectProps> = ({
return (
<Flex className='position-relative'>
<DatePicker
id={id}
className={`${className} ${!isValid && touched ? 'invalid' : ''}`}
dateFormat={dateFormat}
onFocus={() => setTouched(true)}
Expand Down
73 changes: 31 additions & 42 deletions frontend/web/components/EditPermissions.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React, { FC, forwardRef, useCallback, useEffect, useState } from 'react'
import Field from './base/forms/Field'
import Icon from './icons/Icon'
import { find } from 'lodash'
import { close as closeIcon } from 'ionicons/icons'
import { IonIcon } from '@ionic/react'
import _data from 'common/data/base/_data'
import {
AvailablePermission,
Expand Down Expand Up @@ -55,7 +55,6 @@ import {

import MyRoleSelect from './MyRoleSelect'
import Panel from './base/grid/Panel'
import InputGroup from './base/forms/InputGroup'
import classNames from 'classnames'
import OrganisationProvider from 'common/providers/OrganisationProvider'
import { useHasPermission } from 'common/providers/Permission'
Expand Down Expand Up @@ -934,47 +933,37 @@ const _EditPermissionsModal: FC<EditPermissionModalType> = withAdminPermissions(
</div>
{roles && level === 'organisation' && (
<FormGroup className='px-4'>
<InputGroup
component={
<div>
<Row>
<strong style={{ width: 70 }}>Roles: </strong>
{rolesAdded?.map((r) => (
<Row
key={r.id}
onClick={() => removeOwner(r.id)}
className='chip'
style={{ marginBottom: 4, marginTop: 4 }}
>
<span className='font-weight-bold'>{r.name}</span>
<span className='chip-icon ion'>
<IonIcon
icon={closeIcon}
style={{ fontSize: '13px' }}
/>
</span>
</Row>
))}
<Button
theme='text'
onClick={() => setShowRoles(true)}
style={{ width: 70 }}
>
Add Role
</Button>
</Row>
</div>
}
type='text'
<Field
className='full-width'
title='Assign roles'
tooltip='Assigns what role the user/group will have'
inputProps={{
className: 'full-width',
style: { minHeight: 80 },
}}
className='full-width'
placeholder='Add an optional description...'
/>
>
<div>
<Row>
<strong style={{ width: 70 }}>Roles: </strong>
{rolesAdded?.map((r) => (
<Row
key={r.id}
onClick={() => removeOwner(r.id)}
className='chip'
style={{ marginBottom: 4, marginTop: 4 }}
>
<span className='font-weight-bold'>{r.name}</span>
<span className='chip-icon'>
<Icon name='close' width={18} />
</span>
</Row>
))}
<Button
theme='text'
onClick={() => setShowRoles(true)}
style={{ width: 70 }}
>
Add Role
</Button>
</Row>
</div>
</Field>
</FormGroup>
)}
{level !== 'environment' && level !== 'project' && (
Expand Down
74 changes: 74 additions & 0 deletions frontend/web/components/base/forms/Field.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import React, { FC, ReactNode } from 'react'
import cn from 'classnames'
import { TooltipProps } from 'components/Tooltip'
import FieldContext from './FieldContext'
import FieldError from './FieldError'
import FieldLabel from './FieldLabel'

interface FieldProps {
title?: ReactNode
required?: boolean
tooltip?: string
tooltipPlace?: TooltipProps['place']
// Wires the label to the control. Pass it when the child control accepts
// an id; omitting it is a visible statement that the label is decorative.
htmlFor?: string
// Inline error rendered beneath the control. When htmlFor is set the error
// gets `${htmlFor}-error`, so the control can reference it through
// aria-describedby.
error?: ReactNode
className?: string
noMargin?: boolean
children: ReactNode
}

// The field skeleton: wrapper, label, control, error. Layout only — unlike
// the retired InputGroup `component` slot it never pretends to wire a
// control it cannot reach; wiring happens through the explicit htmlFor.
// When htmlFor is set, FieldContext carries it to house controls (Input),
// which adopt it as their default id and pick up the error's
// aria-describedby automatically, so the id is declared exactly once.
const Field: FC<FieldProps> = ({
children,
className,
error,
htmlFor,
noMargin,
required,
title,
tooltip,
tooltipPlace,
}) => {
const content = (
<div className={cn(className, { 'form-group': !noMargin })}>
{(!!title || !!tooltip) && (
<FieldLabel
htmlFor={htmlFor}
required={required}
tooltip={tooltip}
tooltipPlace={tooltipPlace}
>
{title}
</FieldLabel>
)}
{children}
<FieldError id={htmlFor ? `${htmlFor}-error` : undefined} error={error} />
</div>
)
if (!htmlFor) {
return content
}
return (
<FieldContext.Provider
value={{
controlId: htmlFor,
errorId: `${htmlFor}-error`,
hasError: !!error,
}}
>
{content}
</FieldContext.Provider>
)
}

export default Field
Loading
Loading