diff --git a/src/drafts/SelectPanel2/SelectPanel.features.stories.tsx.bak b/src/drafts/SelectPanel2/SelectPanel.features.stories.tsx.bak deleted file mode 100644 index f8e94ff8f7d..00000000000 --- a/src/drafts/SelectPanel2/SelectPanel.features.stories.tsx.bak +++ /dev/null @@ -1,305 +0,0 @@ -import React, {useState, useRef} from 'react' -import {ComponentMeta} from '@storybook/react' - -import Box from '../../Box' -import {Button} from '../../Button' -import {ItemInput} from '../../deprecated/ActionList/List' -import {SelectPanel} from './SelectPanel' -import {TriangleDownIcon} from '@primer/octicons-react' -import type {OverlayProps} from '../../Overlay' - -export default { - title: 'Drafts/Components/SelectPanel/Features', - component: SelectPanel, -} as ComponentMeta - -function getColorCircle(color: string) { - return function () { - return ( - - ) - } -} - -const items = [ - {leadingVisual: getColorCircle('#a2eeef'), text: 'enhancement', id: 1}, - {leadingVisual: getColorCircle('#d73a4a'), text: 'bug', id: 2}, - {leadingVisual: getColorCircle('#0cf478'), text: 'good first issue', id: 3}, - {leadingVisual: getColorCircle('#ffd78e'), text: 'design', id: 4}, - {leadingVisual: getColorCircle('#ff0000'), text: 'blocker', id: 5}, - {leadingVisual: getColorCircle('#a4f287'), text: 'backend', id: 6}, - {leadingVisual: getColorCircle('#8dc6fc'), text: 'frontend', id: 7}, -] - -export const SingleSelectStory = () => { - const [selected, setSelected] = React.useState(items[0]) - const [filter, setFilter] = React.useState('') - const filteredItems = items.filter(item => item.text.toLowerCase().startsWith(filter.toLowerCase())) - const [open, setOpen] = useState(false) - - return ( - <> -

Single Select Panel

-
Please select a label that describe your issue:
- ( - - )} - placeholderText="Filter Labels" - open={open} - onOpenChange={setOpen} - items={filteredItems} - selected={selected} - onSelectedChange={setSelected} - onFilterChange={setFilter} - showItemDividers={true} - overlayProps={{width: 'small', height: 'xsmall'}} - /> - - ) -} -SingleSelectStory.storyName = 'Single Select' - -export const ExternalAnchorStory = () => { - const [selected, setSelected] = React.useState(items[0]) - const [filter, setFilter] = React.useState('') - const filteredItems = items.filter(item => item.text.toLowerCase().startsWith(filter.toLowerCase())) - const [open, setOpen] = useState(false) - const buttonRef = useRef(null) - - return ( - <> -

Select Panel With External Anchor

- - - - ) -} -ExternalAnchorStory.storyName = 'With External Anchor' - -export const SelectPanelHeightInitialWithOverflowingItemsStory = () => { - const [selected, setSelected] = React.useState(items[0]) - const [filter, setFilter] = React.useState('') - const filteredItems = items.filter(item => item.text.toLowerCase().startsWith(filter.toLowerCase())) - const [open, setOpen] = useState(false) - - return ( - <> -

Single Select Panel

-
Please select a label that describe your issue:
- ( - - )} - placeholderText="Filter Labels" - open={open} - onOpenChange={setOpen} - items={filteredItems} - selected={selected} - onSelectedChange={setSelected} - onFilterChange={setFilter} - showItemDividers={true} - overlayProps={{width: 'small', height: 'initial', maxHeight: 'xsmall'}} - /> - - ) -} -SelectPanelHeightInitialWithOverflowingItemsStory.storyName = 'SelectPanel, Height: Initial, Overflowing Items' - -export const SelectPanelHeightInitialWithUnderflowingItemsStory = () => { - const underflowingItems = [items[0], items[1]] - const [selected, setSelected] = React.useState(underflowingItems[0]) - const [filter, setFilter] = React.useState('') - const filteredItems = underflowingItems.filter(item => item.text.toLowerCase().startsWith(filter.toLowerCase())) - const [open, setOpen] = useState(false) - - return ( - <> -

Single Select Panel

-
Please select a label that describe your issue:
- ( - - )} - placeholderText="Filter Labels" - open={open} - onOpenChange={setOpen} - items={filteredItems} - selected={selected} - onSelectedChange={setSelected} - onFilterChange={setFilter} - showItemDividers={true} - overlayProps={{width: 'small', height: 'initial', maxHeight: 'xsmall'}} - /> - - ) -} -SelectPanelHeightInitialWithUnderflowingItemsStory.storyName = 'SelectPanel, Height: Initial, Underflowing Items' - -export const SelectPanelHeightInitialWithUnderflowingItemsAfterFetch = () => { - const [selected, setSelected] = React.useState(items[0]) - const [filter, setFilter] = React.useState('') - const [fetchedItems, setFetchedItems] = useState([]) - const filteredItems = React.useMemo( - () => fetchedItems.filter(item => item.text.toLowerCase().startsWith(filter.toLowerCase())), - [fetchedItems, filter], - ) - const [open, setOpen] = useState(false) - const [height, setHeight] = useState('auto') - - const onOpenChange = () => { - setOpen(!open) - setTimeout(() => { - setFetchedItems([items[0], items[1]]) - setHeight('initial') - }, 1500) - } - - return ( - <> -

Single Select Panel

-
Please select a label that describe your issue:
- ( - - )} - placeholderText="Filter Labels" - open={open} - onOpenChange={onOpenChange} - loading={filteredItems.length === 0} - items={filteredItems} - selected={selected} - onSelectedChange={setSelected} - onFilterChange={setFilter} - showItemDividers={true} - overlayProps={{width: 'small', height, maxHeight: 'xsmall'}} - /> - - ) -} -SelectPanelHeightInitialWithUnderflowingItemsAfterFetch.storyName = - 'SelectPanel, Height: Initial, Underflowing Items (After Fetch)' - -export const SelectPanelAboveTallBody = () => { - const [selected, setSelected] = React.useState(items[0]) - const [filter, setFilter] = React.useState('') - const filteredItems = items.filter(item => item.text.toLowerCase().startsWith(filter.toLowerCase())) - const [open, setOpen] = useState(false) - - return ( - <> -

Single Select Panel

-
Please select a label that describe your issue:
- ( - - )} - placeholderText="Filter Labels" - open={open} - onOpenChange={setOpen} - items={filteredItems} - selected={selected} - onSelectedChange={setSelected} - onFilterChange={setFilter} - showItemDividers={true} - overlayProps={{width: 'small', height: 'xsmall'}} - /> -
- This element makes the body really tall. This is to test that we do not have layout/focus issues if the Portal - is far down the page -
- - ) -} -SelectPanelAboveTallBody.storyName = 'SelectPanel, Above a Tall Body' - -export const SelectPanelHeightAndScroll = () => { - const longItems = [...items, ...items, ...items, ...items, ...items, ...items, ...items, ...items] - const [selectedA, setSelectedA] = React.useState(longItems[0]) - const [selectedB, setSelectedB] = React.useState(longItems[0]) - const [filter, setFilter] = React.useState('') - const filteredItems = longItems.filter(item => item.text.toLowerCase().startsWith(filter.toLowerCase())) - const [openA, setOpenA] = useState(false) - const [openB, setOpenB] = useState(false) - - return ( - <> -

With height:medium

- ( - - )} - placeholderText="Filter Labels" - open={openA} - onOpenChange={setOpenA} - items={filteredItems} - selected={selectedA} - onSelectedChange={setSelectedA} - onFilterChange={setFilter} - showItemDividers={true} - overlayProps={{height: 'medium'}} - /> -

With height:auto, maxheight:medium

- ( - - )} - placeholderText="Filter Labels" - open={openB} - onOpenChange={setOpenB} - items={filteredItems} - selected={selectedB} - onSelectedChange={setSelectedB} - onFilterChange={setFilter} - showItemDividers={true} - overlayProps={{ - height: 'auto', - maxHeight: 'medium', - }} - /> - - ) -} -SelectPanelHeightAndScroll.storyName = 'SelectPanel, Height and Scroll' diff --git a/src/drafts/SelectPanel2/stories/SelectPanel.default.stories.tsx b/src/drafts/SelectPanel2/stories/SelectPanel.default.stories.tsx new file mode 100644 index 00000000000..923ffb5ad53 --- /dev/null +++ b/src/drafts/SelectPanel2/stories/SelectPanel.default.stories.tsx @@ -0,0 +1,111 @@ +import React from 'react' +import {SelectPanel} from '../SelectPanel' +import {ActionList, Box} from '../../../index' +import data from './mock-data' + +export default { + title: 'Drafts/Components/SelectPanel', + component: SelectPanel, +} + +export const Default = () => { + const initialSelectedLabels = data.issue.labelIds // mock initial state: has selected labels + const [selectedLabelIds, setSelectedLabelIds] = React.useState(initialSelectedLabels) + + /* Selection */ + const onLabelSelect = (labelId: string) => { + if (!selectedLabelIds.includes(labelId)) setSelectedLabelIds([...selectedLabelIds, labelId]) + else setSelectedLabelIds(selectedLabelIds.filter(id => id !== labelId)) + } + const onClearSelection = () => { + setSelectedLabelIds([]) + } + + const onSubmit = () => { + data.issue.labelIds = selectedLabelIds // pretending to persist changes + } + + /* Filtering */ + const [filteredLabels, setFilteredLabels] = React.useState(data.labels) + const [query, setQuery] = React.useState('') + + const onSearchInputChange: React.ChangeEventHandler = event => { + const query = event.currentTarget.value + setQuery(query) + + if (query === '') setFilteredLabels(data.labels) + else { + setFilteredLabels( + data.labels + .map(label => { + if (label.name.toLowerCase().startsWith(query)) return {priority: 1, label} + else if (label.name.toLowerCase().includes(query)) return {priority: 2, label} + else if (label.description?.toLowerCase().includes(query)) return {priority: 3, label} + else return {priority: -1, label} + }) + .filter(result => result.priority > 0) + .map(result => result.label), + ) + } + } + + const sortingFn = (itemA: {id: string}, itemB: {id: string}) => { + const initialSelectedIds = data.issue.labelIds + if (initialSelectedIds.includes(itemA.id) && initialSelectedIds.includes(itemB.id)) return 1 + else if (initialSelectedIds.includes(itemA.id)) return -1 + else if (initialSelectedIds.includes(itemB.id)) return 1 + else return 1 + } + + const itemsToShow = query ? filteredLabels : data.labels.sort(sortingFn) + + return ( + <> + { + /* optional callback, for example: for multi-step overlay or to fire sync actions */ + // eslint-disable-next-line no-console + console.log('panel was closed') + }} + onClearSelection={onClearSelection} + > + Assign label + + + + + + {itemsToShow.length === 0 ? ( + + Try a different search term + + ) : ( + + {itemsToShow.map(label => ( + onLabelSelect(label.id)} + selected={selectedLabelIds.includes(label.id)} + > + + + + {label.name} + {label.description} + + ))} + + )} + + + Edit labels + + + + ) +} diff --git a/src/drafts/SelectPanel2/SelectPanel.stories.tsx b/src/drafts/SelectPanel2/stories/SelectPanel.examples.stories.tsx similarity index 53% rename from src/drafts/SelectPanel2/SelectPanel.stories.tsx rename to src/drafts/SelectPanel2/stories/SelectPanel.examples.stories.tsx index b4dd23b130f..81698bdb900 100644 --- a/src/drafts/SelectPanel2/SelectPanel.stories.tsx +++ b/src/drafts/SelectPanel2/stories/SelectPanel.examples.stories.tsx @@ -1,22 +1,19 @@ import React from 'react' -import {SelectPanel} from './SelectPanel' -import {ActionList, ActionMenu, Avatar, Box, Button, Flash, Link, Text, ToggleSwitch} from '../../../src/index' -import { - ArrowRightIcon, - AlertIcon, - EyeIcon, - GitBranchIcon, - TriangleDownIcon, - TagIcon, - GearIcon, -} from '@primer/octicons-react' +import {SelectPanel} from '../SelectPanel' +import {ActionList, ActionMenu, Avatar, Box, Button, Flash} from '../../../index' +import {ArrowRightIcon, AlertIcon, EyeIcon, GitBranchIcon, TriangleDownIcon, GearIcon} from '@primer/octicons-react' import data from './mock-data' +export default { + title: 'Drafts/Components/SelectPanel/Examples', + component: SelectPanel, +} + const getCircle = (color: string) => ( ) -export const AControlled = () => { +export const Minimal = () => { const initialSelectedLabels = data.issue.labelIds // mock initial state: has selected labels const [selectedLabelIds, setSelectedLabelIds] = React.useState(initialSelectedLabels) @@ -26,11 +23,6 @@ export const AControlled = () => { else setSelectedLabelIds(selectedLabelIds.filter(id => id !== labelId)) } - const onClearSelection = () => { - // soft set, does not save until submit - setSelectedLabelIds([]) - } - const onSubmit = () => { data.issue.labelIds = selectedLabelIds // pretending to persist changes @@ -38,62 +30,104 @@ export const AControlled = () => { console.log('form submitted') } + const sortingFn = (itemA: {id: string}, itemB: {id: string}) => { + const initialSelectedIds = data.issue.labelIds + if (initialSelectedIds.includes(itemA.id) && initialSelectedIds.includes(itemB.id)) return 1 + else if (initialSelectedIds.includes(itemA.id)) return -1 + else if (initialSelectedIds.includes(itemB.id)) return 1 + else return 1 + } + + const itemsToShow = data.labels.sort(sortingFn) + + return ( + <> +

Minimal SelectPanel

+ + + Assign label + + + {itemsToShow.map(label => ( + onLabelSelect(label.id)} + selected={selectedLabelIds.includes(label.id)} + > + {getCircle(label.color)} + {label.name} + {label.description} + + ))} + + + + + ) +} + +export const WithGroups = () => { + /* Selection */ + const initialAssigneeIds = data.issue.assigneeIds // mock initial state + const [selectedAssigneeIds, setSelectedAssigneeIds] = React.useState(initialAssigneeIds) + + const onCollaboratorSelect = (colloratorId: string) => { + if (!selectedAssigneeIds.includes(colloratorId)) setSelectedAssigneeIds([...selectedAssigneeIds, colloratorId]) + else setSelectedAssigneeIds(selectedAssigneeIds.filter(id => id !== colloratorId)) + } + + const onClearSelection = () => setSelectedAssigneeIds([]) + const onSubmit = () => { + data.issue.assigneeIds = selectedAssigneeIds // pretending to persist changes + } + /* Filtering */ - const [filteredLabels, setFilteredLabels] = React.useState(data.labels) + const [filteredUsers, setFilteredUsers] = React.useState(data.collaborators) const [query, setQuery] = React.useState('') const onSearchInputChange: React.ChangeEventHandler = event => { const query = event.currentTarget.value setQuery(query) - if (query === '') setFilteredLabels(data.labels) + if (query === '') setFilteredUsers(data.collaborators) else { - // Note: in the future, we should probably add a highlight for matching text - setFilteredLabels( - data.labels - .map(label => { - if (label.name.toLowerCase().startsWith(query)) return {priority: 1, label} - else if (label.name.toLowerCase().includes(query)) return {priority: 2, label} - else if (label.description?.toLowerCase().includes(query)) return {priority: 3, label} - else return {priority: -1, label} + setFilteredUsers( + data.collaborators + .map(collaborator => { + if (collaborator.login.toLowerCase().startsWith(query)) return {priority: 1, collaborator} + else if (collaborator.name.startsWith(query)) return {priority: 2, collaborator} + else if (collaborator.login.toLowerCase().includes(query)) return {priority: 3, collaborator} + else if (collaborator.name.toLowerCase().includes(query)) return {priority: 4, collaborator} + else return {priority: -1, collaborator} }) .filter(result => result.priority > 0) - .map(result => result.label), + .map(result => result.collaborator), ) } } const sortingFn = (itemA: {id: string}, itemB: {id: string}) => { - const initialSelectedIds = data.issue.labelIds + const initialSelectedIds = data.issue.assigneeIds if (initialSelectedIds.includes(itemA.id) && initialSelectedIds.includes(itemB.id)) return 1 else if (initialSelectedIds.includes(itemA.id)) return -1 else if (initialSelectedIds.includes(itemB.id)) return 1 else return 1 } - const itemsToShow = query ? filteredLabels : data.labels.sort(sortingFn) + const itemsToShow = query ? filteredUsers : data.collaborators.sort(sortingFn) return ( <> -

Controlled SelectPanel

+

SelectPanel with groups

- { - /* optional callback, for example: for multi-step overlay or to fire sync actions */ - // eslint-disable-next-line no-console - console.log('panel was closed') - }} - // API TODO: onClearSelection feels even more odd on the parent, instead of on the header. - onClearSelection={onClearSelection} - > - Assign label - {/* API TODO: header and heading is confusing. maybe skip header completely. */} + + + Reviewers + @@ -104,29 +138,52 @@ export const AControlled = () => { ) : ( - {itemsToShow.map(label => ( - onLabelSelect(label.id)} - selected={selectedLabelIds.includes(label.id)} - > - {getCircle(label.color)} - {label.name} - {label.description} - - ))} + + Suggestions + {itemsToShow + .filter(collaborator => collaborator.recommended) + .map(collaborator => ( + onCollaboratorSelect(collaborator.id)} + selected={selectedAssigneeIds.includes(collaborator.id)} + > + + + + {collaborator.login} + {collaborator.login} + + ))} + + + Everyone else + {itemsToShow + .filter(collaborator => !collaborator.recommended) + .map(collaborator => ( + onCollaboratorSelect(collaborator.id)} + selected={selectedAssigneeIds.includes(collaborator.id)} + > + + + + {collaborator.login} + {collaborator.login} + + ))} + )} - - Edit labels - + ) } -export const BWithSuspendedList = () => { +export const AsyncWithSuspendedList = () => { const [query, setQuery] = React.useState('') const onSearchInputChange: React.ChangeEventHandler = event => { @@ -136,7 +193,7 @@ export const BWithSuspendedList = () => { return ( <> -

Suspended list

+

Async: Suspended list

Fetching items once when the panel is opened (like repo labels)

Assign label @@ -210,59 +267,6 @@ const SuspendedActionList: React.FC<{query: string}> = ({query}) => { ) } -export const CAsyncSearchWithSuspenseKey = () => { - // issue `data` is already pre-fetched - // `users` are fetched async on search - - const [query, setQuery] = React.useState('') - const onSearchInputChange: React.ChangeEventHandler = event => { - const query = event.currentTarget.value - setQuery(query) - } - - /* Selection */ - const initialAssigneeIds: string[] = data.issue.assigneeIds - const [selectedUserIds, setSelectedUserIds] = React.useState(initialAssigneeIds) - const onUserSelect = (userId: string) => { - if (!selectedUserIds.includes(userId)) setSelectedUserIds([...selectedUserIds, userId]) - else setSelectedUserIds(selectedUserIds.filter(id => id !== userId)) - } - - const onSubmit = () => { - data.issue.assigneeIds = selectedUserIds // pretending to persist changes - - // eslint-disable-next-line no-console - console.log('form submitted') - } - - return ( - <> -

Async search with useTransition

-

Fetching items on every keystroke search (like github users)

- - - Select assignees - - - - - {/* Reset suspense boundary to trigger refetch on query - Docs reference: https://react.dev/reference/react/Suspense#resetting-suspense-boundaries-on-navigation - */} - Fetching users...}> - - - - - - ) -} - /* `data` is already pre-fetched with the issue `users` are fetched async on search @@ -316,7 +320,7 @@ const SearchableUserList: React.FC<{ `data` is already pre-fetched with the issue `users` are fetched async on search */ -export const DAsyncSearchWithUseTransition = () => { +export const AsyncSearchWithUseTransition = () => { const [isPending, startTransition] = React.useTransition() const [query, setQuery] = React.useState('') @@ -341,315 +345,65 @@ export const DAsyncSearchWithUseTransition = () => { return ( <> -

Async search with useTransition

+

Async: search with useTransition

Fetching items on every keystroke search (like github users)

- + Select assignees - + - Fetching users...}> - - - + + ) } -export const TODO2SingleSelection = () =>

TODO

- -export const HWithFilterButtons = () => { - const [selectedFilter, setSelectedFilter] = React.useState<'branches' | 'tags'>('branches') +export const OpenFromMenu = () => { + /* Open state */ + const [menuOpen, setMenuOpen] = React.useState(false) + const [selectPanelOpen, setSelectPanelOpen] = React.useState(false) + const buttonRef = React.useRef(null) /* Selection */ - const [savedInitialRef, setSavedInitialRef] = React.useState(data.ref) - const [selectedRef, setSelectedRef] = React.useState(savedInitialRef) - - const onSubmit = () => { - setSavedInitialRef(selectedRef) - data.ref = selectedRef // pretending to persist changes - - // eslint-disable-next-line no-console - console.log('form submitted') - } - - /* Filter */ - const [query, setQuery] = React.useState('') - const onSearchInputChange: React.ChangeEventHandler = event => { - const query = event.currentTarget.value - setQuery(query) - } + const [selectedSetting, setSelectedSetting] = React.useState('All activity') + const [selectedEvents, setSelectedEvents] = React.useState([]) - const [filteredRefs, setFilteredRefs] = React.useState(data.branches) - const setSearchResults = (query: string, selectedFilter: 'branches' | 'tags') => { - if (query === '') setFilteredRefs(data[selectedFilter]) - else { - setFilteredRefs( - data[selectedFilter] - .map(item => { - if (item.name.toLowerCase().startsWith(query)) return {priority: 1, item} - else if (item.name.toLowerCase().includes(query)) return {priority: 2, item} - else return {priority: -1, item} - }) - .filter(result => result.priority > 0) - .map(result => result.item), - ) - } + const onEventSelect = (event: string) => { + if (!selectedEvents.includes(event)) setSelectedEvents([...selectedEvents, event]) + else setSelectedEvents(selectedEvents.filter(name => name !== event)) } - React.useEffect( - function updateSearchResults() { - setSearchResults(query, selectedFilter) - }, - [query, selectedFilter], - ) - - const sortingFn = (ref: {id: string}) => { - if (ref.id === savedInitialRef) return -1 - else return 1 + const onSelectPanelSubmit = () => { + setSelectedSetting('Custom') } - const itemsToShow = query ? filteredRefs : data[selectedFilter].sort(sortingFn) + const itemsToShow = ['Issues', 'Pull requests', 'Releases', 'Discussions', 'Security alerts'] return ( <> -

With Filter Buttons

- - - - {savedInitialRef} - - - - - - - - - - - - {itemsToShow.length === 0 ? ( - - Try a different search term - - ) : ( - - {itemsToShow.map(item => ( - setSelectedRef(item.id)} - > - {item.name} - {item.trailingInfo} - - ))} - - )} - - - {/* @ts-ignore TODO as prop is not identified by button? */} - - View all {selectedFilter} - - - - - ) -} - -export const EMinimal = () => { - const initialSelectedLabels = data.issue.labelIds // mock initial state: has selected labels - const [selectedLabelIds, setSelectedLabelIds] = React.useState(initialSelectedLabels) - - /* Selection */ - const onLabelSelect = (labelId: string) => { - if (!selectedLabelIds.includes(labelId)) setSelectedLabelIds([...selectedLabelIds, labelId]) - else setSelectedLabelIds(selectedLabelIds.filter(id => id !== labelId)) - } - - const onSubmit = () => { - data.issue.labelIds = selectedLabelIds // pretending to persist changes - - // eslint-disable-next-line no-console - console.log('form submitted') - } - - const sortingFn = (itemA: {id: string}, itemB: {id: string}) => { - const initialSelectedIds = data.issue.labelIds - if (initialSelectedIds.includes(itemA.id) && initialSelectedIds.includes(itemB.id)) return 1 - else if (initialSelectedIds.includes(itemA.id)) return -1 - else if (initialSelectedIds.includes(itemB.id)) return 1 - else return 1 - } - - const itemsToShow = data.labels.sort(sortingFn) - - return ( - <> -

Minimal SelectPanel

- - - Assign label - - - {itemsToShow.map(label => ( - onLabelSelect(label.id)} - selected={selectedLabelIds.includes(label.id)} - > - {getCircle(label.color)} - {label.name} - {label.description} - - ))} - - - - - ) -} - -export const FExternalAnchor = () => { - const initialSelectedLabels = data.issue.labelIds // mock initial state: has selected labels - const [selectedLabelIds, setSelectedLabelIds] = React.useState(initialSelectedLabels) - - /* Selection */ - const onLabelSelect = (labelId: string) => { - if (!selectedLabelIds.includes(labelId)) setSelectedLabelIds([...selectedLabelIds, labelId]) - else setSelectedLabelIds(selectedLabelIds.filter(id => id !== labelId)) - } - - const onSubmit = () => { - data.issue.labelIds = selectedLabelIds // pretending to persist changes - - // eslint-disable-next-line no-console - console.log('form submitted') - } - - const sortingFn = (itemA: {id: string}, itemB: {id: string}) => { - const initialSelectedIds = data.issue.labelIds - if (initialSelectedIds.includes(itemA.id) && initialSelectedIds.includes(itemB.id)) return 1 - else if (initialSelectedIds.includes(itemA.id)) return -1 - else if (initialSelectedIds.includes(itemB.id)) return 1 - else return 1 - } - - const itemsToShow = data.labels.sort(sortingFn) - - const anchorRef = React.useRef(null) - const [open, setOpen] = React.useState(false) - - return ( - <> -

With External Anchor

-

- To use an external anchor, pass an `anchorRef` to `SelectPanel`. You would also need to control the `open` state - with `onSubmit` and `onCancel` -

- - - - { - setOpen(false) // close on submit - onSubmit() - }} - onCancel={() => setOpen(false)} // close on cancel - > - - {itemsToShow.map(label => ( - onLabelSelect(label.id)} - selected={selectedLabelIds.includes(label.id)} - > - {getCircle(label.color)} - {label.name} - {label.description} - - ))} - - - - - ) -} - -export const GOpenFromMenu = () => { - /* Open state */ - const [menuOpen, setMenuOpen] = React.useState(false) - const [selectPanelOpen, setSelectPanelOpen] = React.useState(false) - const buttonRef = React.useRef(null) - - /* Selection */ - const [selectedSetting, setSelectedSetting] = React.useState('All activity') - const [selectedEvents, setSelectedEvents] = React.useState([]) - - const onEventSelect = (event: string) => { - if (!selectedEvents.includes(event)) setSelectedEvents([...selectedEvents, event]) - else setSelectedEvents(selectedEvents.filter(name => name !== event)) - } - - const onSelectPanelSubmit = () => { - setSelectedSetting('Custom') - } - - const itemsToShow = ['Issues', 'Pull requests', 'Releases', 'Discussions', 'Security alerts'] - - return ( - <> -

Open from ActionMenu

- - - This implementation will most likely change.{' '} - - See decision log for more details. - - -

- To open SelectPanel from a menu, you would need to use an external anchor and pass `anchorRef` to `SelectPanel`. - You would also need to control the `open` state for both ActionMenu and SelectPanel. -
-
- Important: Pass the same `anchorRef` to both ActionMenu and SelectPanel -

+

Open from ActionMenu

+ + + This implementation will most likely change.{' '} + + See decision log for more details. + + +

+ To open SelectPanel from a menu, you would need to use an external anchor and pass `anchorRef` to `SelectPanel`. + You would also need to control the `open` state for both ActionMenu and SelectPanel. +
+
+ Important: Pass the same `anchorRef` to both ActionMenu and SelectPanel +

+ +
+ {itemsToShow.length === 0 ? ( Try a different search term ) : ( - - {itemsToShow.map(collaborator => ( + + {itemsToShow.map(item => ( onCollaboratorSelect(collaborator.id)} - selected={selectedAssigneeIds.includes(collaborator.id)} - disabled={selectedAssigneeIds.length >= MAX_LIMIT && !selectedAssigneeIds.includes(collaborator.id)} + key={item.id} + selected={selectedRef === item.id} + onSelect={() => setSelectedRef(item.id)} > - - - - {collaborator.login} - {collaborator.login} + {item.name} + {item.trailingInfo} ))} )} - -
- - ) -} - -export const JWithErrors = () => { - const [searchBroken, setSearchBroken] = React.useState(true) - const [issuesBroken, setIssuesBroken] = React.useState(false) - - /* Selection */ - const initialAssigneeIds = data.collaborators.slice(0, 3).map(c => c.id) // mock initial state - const [selectedAssigneeIds, setSelectedAssigneeIds] = React.useState(initialAssigneeIds) - - const onCollaboratorSelect = (colloratorId: string) => { - if (!selectedAssigneeIds.includes(colloratorId)) setSelectedAssigneeIds([...selectedAssigneeIds, colloratorId]) - else setSelectedAssigneeIds(selectedAssigneeIds.filter(id => id !== colloratorId)) - } - - const onClearSelection = () => setSelectedAssigneeIds([]) - const onSubmit = () => { - data.issue.assigneeIds = selectedAssigneeIds // pretending to persist changes - } - - /* Filtering */ - const [filteredUsers, setFilteredUsers] = React.useState( - searchBroken ? data.collaborators.filter(c => initialAssigneeIds.includes(c.id)) : data.collaborators, - ) - - const [query, setQuery] = React.useState('') - - const onSearchInputChange: React.ChangeEventHandler = event => { - const query = event.currentTarget.value - setQuery(query) - - if (query === '') setFilteredUsers(data.collaborators) - else { - // if search is broken, only show assignees, not all collaborators - const allCollaborators = searchBroken - ? data.collaborators.filter(c => initialAssigneeIds.includes(c.id)) - : data.collaborators - - setFilteredUsers( - allCollaborators - .map(collaborator => { - if (collaborator.login.toLowerCase().startsWith(query)) return {priority: 1, collaborator} - else if (collaborator.name.startsWith(query)) return {priority: 2, collaborator} - else if (collaborator.login.toLowerCase().includes(query)) return {priority: 3, collaborator} - else if (collaborator.name.toLowerCase().includes(query)) return {priority: 4, collaborator} - else return {priority: -1, collaborator} - }) - .filter(result => result.priority > 0) - .map(result => result.collaborator), - ) - } - } - - const sortingFn = (itemA: {id: string}, itemB: {id: string}) => { - const initialSelectedIds = data.issue.assigneeIds - if (initialSelectedIds.includes(itemA.id) && initialSelectedIds.includes(itemB.id)) return 1 - else if (initialSelectedIds.includes(itemA.id)) return -1 - else if (initialSelectedIds.includes(itemB.id)) return 1 - else return 1 - } - - const itemsToShow = query ? filteredUsers : data.collaborators.sort(sortingFn) - - return ( - <> -

SelectPanel with Errors

- - - - - Break search API - - - Turn on to show error message while searching - - - setSearchBroken(enabled)} - aria-labelledby="switch-label" - aria-describedby="switch-caption" - /> - - - - - Break issues API - - - Turn on to break everything and show big error in panel - - - setIssuesBroken(enabled)} - aria-labelledby="break-issues-label" - aria-describedby="break-issues-caption" - /> - - - - - Assignees - - - - - - {issuesBroken ? ( - - Try again or if the problem persists, contact support - - ) : ( - <> - {query && searchBroken ? ( - - We couldn't load all collaborators. Try again or if the problem persists,{' '} - contact support - - ) : null} - {itemsToShow.length === 0 ? ( - - Try a different search term - - ) : ( - - {itemsToShow.map(collaborator => ( - onCollaboratorSelect(collaborator.id)} - selected={selectedAssigneeIds.includes(collaborator.id)} - > - - - - {collaborator.login} - {collaborator.login} - - ))} - - )} - - )} - - + + {/* @ts-ignore TODO as prop is not identified by button? */} + + View all {selectedFilter} + + ) @@ -1051,24 +626,6 @@ const fetchUsers = async (query: string, delay: number) => { user.login.toLowerCase().includes(query.toLowerCase()) || user.name.toLowerCase().includes(query.toLowerCase()) ) }) - // i went harder on this than is necessary 😅 - // .map(user => { - // if (user.login.toLowerCase().startsWith(query)) return {priority: 1, user} - // else if (user.login.toLowerCase().includes(query)) return {priority: 2, user} - // else if (user.name.toLowerCase().includes(query)) return {priority: 3, user} - // else return {priority: 4, user} - // }) - // .sort((userA, userB) => (userA.priority > userB.priority ? 1 : -1)) - // .sort((userA, userB) => { - // // second level sort: collaborators show up first - // if ( - // data.collaborators.find(c => c.id === userA.user.id) && - // !data.collaborators.find(c => c.id === userB.user.id) - // ) { - // return -1 - // } else return 1 - // }) - // .map(result => result.user) } /* lifted from the examples at https://react.dev/reference/react/Suspense */ @@ -1097,8 +654,3 @@ function use(promise) { throw promise } } - -export default { - title: 'Drafts/Components/SelectPanel', - component: SelectPanel, -} diff --git a/src/drafts/SelectPanel2/stories/SelectPanel.features.stories.tsx b/src/drafts/SelectPanel2/stories/SelectPanel.features.stories.tsx new file mode 100644 index 00000000000..255edd3a9d2 --- /dev/null +++ b/src/drafts/SelectPanel2/stories/SelectPanel.features.stories.tsx @@ -0,0 +1,386 @@ +import React from 'react' +import {SelectPanel} from '../SelectPanel' +import {ActionList, Avatar, Box, Button, Link, Text, ToggleSwitch} from '../../../index' +import {TagIcon, GearIcon} from '@primer/octicons-react' +import data from './mock-data' + +export default { + title: 'Drafts/Components/SelectPanel/Features', + component: SelectPanel, +} + +const getCircle = (color: string) => ( + +) + +export const InstantSelectionVariant = () => { + const [selectedTag, setSelectedTag] = React.useState() + + const onSubmit = () => { + if (!selectedTag) return + data.ref = selectedTag // pretending to persist changes + } + + const itemsToShow = data.tags + + return ( + <> +

Instant selection variant

+ + + {selectedTag || 'Choose a tag'} + + + {itemsToShow.map(tag => ( + setSelectedTag(tag.id)} selected={selectedTag === tag.id}> + {tag.name} + + ))} + + + Edit tags + + + + ) +} + +export const SingleSelection = () =>

TODO

+ +export const WithWarning = () => { + /* Selection */ + + const initialAssigneeIds = data.issue.assigneeIds // mock initial state + const [selectedAssigneeIds, setSelectedAssigneeIds] = React.useState(initialAssigneeIds) + const MAX_LIMIT = 3 + + const onCollaboratorSelect = (colloratorId: string) => { + if (!selectedAssigneeIds.includes(colloratorId)) setSelectedAssigneeIds([...selectedAssigneeIds, colloratorId]) + else setSelectedAssigneeIds(selectedAssigneeIds.filter(id => id !== colloratorId)) + } + + const onClearSelection = () => setSelectedAssigneeIds([]) + const onSubmit = () => { + data.issue.assigneeIds = selectedAssigneeIds // pretending to persist changes + } + + /* Filtering */ + const [filteredUsers, setFilteredUsers] = React.useState(data.collaborators) + const [query, setQuery] = React.useState('') + + const onSearchInputChange: React.ChangeEventHandler = event => { + const query = event.currentTarget.value + setQuery(query) + + if (query === '') setFilteredUsers(data.collaborators) + else { + setFilteredUsers( + data.collaborators + .map(collaborator => { + if (collaborator.login.toLowerCase().startsWith(query)) return {priority: 1, collaborator} + else if (collaborator.name.startsWith(query)) return {priority: 2, collaborator} + else if (collaborator.login.toLowerCase().includes(query)) return {priority: 3, collaborator} + else if (collaborator.name.toLowerCase().includes(query)) return {priority: 4, collaborator} + else return {priority: -1, collaborator} + }) + .filter(result => result.priority > 0) + .map(result => result.collaborator), + ) + } + } + + const sortingFn = (itemA: {id: string}, itemB: {id: string}) => { + const initialSelectedIds = data.issue.assigneeIds + if (initialSelectedIds.includes(itemA.id) && initialSelectedIds.includes(itemB.id)) return 1 + else if (initialSelectedIds.includes(itemA.id)) return -1 + else if (initialSelectedIds.includes(itemB.id)) return 1 + else return 1 + } + + const itemsToShow = query ? filteredUsers : data.collaborators.sort(sortingFn) + + return ( + <> +

SelectPanel with warning

+ + + + Assignees + + + + + + {selectedAssigneeIds.length >= MAX_LIMIT ? ( + + You have reached the limit of {MAX_LIMIT} assignees on your free account.{' '} + Upgrade your account. + + ) : null} + + {itemsToShow.length === 0 ? ( + + Try a different search term + + ) : ( + + {itemsToShow.map(collaborator => ( + onCollaboratorSelect(collaborator.id)} + selected={selectedAssigneeIds.includes(collaborator.id)} + disabled={selectedAssigneeIds.length >= MAX_LIMIT && !selectedAssigneeIds.includes(collaborator.id)} + > + + + + {collaborator.login} + {collaborator.login} + + ))} + + )} + + + + + ) +} + +export const WithErrors = () => { + const [searchBroken, setSearchBroken] = React.useState(true) + const [issuesBroken, setIssuesBroken] = React.useState(false) + + /* Selection */ + const initialAssigneeIds = data.collaborators.slice(0, 3).map(c => c.id) // mock initial state + const [selectedAssigneeIds, setSelectedAssigneeIds] = React.useState(initialAssigneeIds) + + const onCollaboratorSelect = (colloratorId: string) => { + if (!selectedAssigneeIds.includes(colloratorId)) setSelectedAssigneeIds([...selectedAssigneeIds, colloratorId]) + else setSelectedAssigneeIds(selectedAssigneeIds.filter(id => id !== colloratorId)) + } + + const onClearSelection = () => setSelectedAssigneeIds([]) + const onSubmit = () => { + data.issue.assigneeIds = selectedAssigneeIds // pretending to persist changes + } + + /* Filtering */ + const [filteredUsers, setFilteredUsers] = React.useState( + searchBroken ? data.collaborators.filter(c => initialAssigneeIds.includes(c.id)) : data.collaborators, + ) + + const [query, setQuery] = React.useState('') + + const onSearchInputChange: React.ChangeEventHandler = event => { + const query = event.currentTarget.value + setQuery(query) + + if (query === '') setFilteredUsers(data.collaborators) + else { + // if search is broken, only show assignees, not all collaborators + const allCollaborators = searchBroken + ? data.collaborators.filter(c => initialAssigneeIds.includes(c.id)) + : data.collaborators + + setFilteredUsers( + allCollaborators + .map(collaborator => { + if (collaborator.login.toLowerCase().startsWith(query)) return {priority: 1, collaborator} + else if (collaborator.name.startsWith(query)) return {priority: 2, collaborator} + else if (collaborator.login.toLowerCase().includes(query)) return {priority: 3, collaborator} + else if (collaborator.name.toLowerCase().includes(query)) return {priority: 4, collaborator} + else return {priority: -1, collaborator} + }) + .filter(result => result.priority > 0) + .map(result => result.collaborator), + ) + } + } + + const sortingFn = (itemA: {id: string}, itemB: {id: string}) => { + const initialSelectedIds = data.issue.assigneeIds + if (initialSelectedIds.includes(itemA.id) && initialSelectedIds.includes(itemB.id)) return 1 + else if (initialSelectedIds.includes(itemA.id)) return -1 + else if (initialSelectedIds.includes(itemB.id)) return 1 + else return 1 + } + + const itemsToShow = query ? filteredUsers : data.collaborators.sort(sortingFn) + + return ( + <> +

SelectPanel with Errors

+ + + + + Break search API + + + Turn on to show error message while searching + + + setSearchBroken(enabled)} + aria-labelledby="switch-label" + aria-describedby="switch-caption" + /> + + + + + Break issues API + + + Turn on to break everything and show big error in panel + + + setIssuesBroken(enabled)} + aria-labelledby="break-issues-label" + aria-describedby="break-issues-caption" + /> + + + + + Assignees + + + + + + {issuesBroken ? ( + + Try again or if the problem persists, contact support + + ) : ( + <> + {query && searchBroken ? ( + + We couldn't load all collaborators. Try again or if the problem persists,{' '} + contact support + + ) : null} + {itemsToShow.length === 0 ? ( + + Try a different search term + + ) : ( + + {itemsToShow.map(collaborator => ( + onCollaboratorSelect(collaborator.id)} + selected={selectedAssigneeIds.includes(collaborator.id)} + > + + + + {collaborator.login} + {collaborator.login} + + ))} + + )} + + )} + + + + + ) +} + +export const ExternalAnchor = () => { + const initialSelectedLabels = data.issue.labelIds // mock initial state: has selected labels + const [selectedLabelIds, setSelectedLabelIds] = React.useState(initialSelectedLabels) + + /* Selection */ + const onLabelSelect = (labelId: string) => { + if (!selectedLabelIds.includes(labelId)) setSelectedLabelIds([...selectedLabelIds, labelId]) + else setSelectedLabelIds(selectedLabelIds.filter(id => id !== labelId)) + } + + const onSubmit = () => { + data.issue.labelIds = selectedLabelIds // pretending to persist changes + + // eslint-disable-next-line no-console + console.log('form submitted') + } + + const sortingFn = (itemA: {id: string}, itemB: {id: string}) => { + const initialSelectedIds = data.issue.labelIds + if (initialSelectedIds.includes(itemA.id) && initialSelectedIds.includes(itemB.id)) return 1 + else if (initialSelectedIds.includes(itemA.id)) return -1 + else if (initialSelectedIds.includes(itemB.id)) return 1 + else return 1 + } + + const itemsToShow = data.labels.sort(sortingFn) + + const anchorRef = React.useRef(null) + const [open, setOpen] = React.useState(false) + + return ( + <> +

With External Anchor

+

+ To use an external anchor, pass an `anchorRef` to `SelectPanel`. You would also need to control the `open` state + with `onSubmit` and `onCancel` +

+ + + + { + setOpen(false) // close on submit + onSubmit() + }} + onCancel={() => setOpen(false)} // close on cancel + > + + {itemsToShow.map(label => ( + onLabelSelect(label.id)} + selected={selectedLabelIds.includes(label.id)} + > + {getCircle(label.color)} + {label.name} + {label.description} + + ))} + + + + + ) +} diff --git a/src/drafts/SelectPanel2/stories/SelectPanel.playground.stories.tsx b/src/drafts/SelectPanel2/stories/SelectPanel.playground.stories.tsx new file mode 100644 index 00000000000..a9d318eccc7 --- /dev/null +++ b/src/drafts/SelectPanel2/stories/SelectPanel.playground.stories.tsx @@ -0,0 +1,135 @@ +import React from 'react' +import {Meta, StoryFn} from '@storybook/react' +import {SelectPanel, SelectPanelProps} from '../SelectPanel' +import {ActionList, Box} from '../../../index' +import data from './mock-data' + +export default { + title: 'Drafts/Components/SelectPanel/Playground', + component: SelectPanel, + + args: { + title: 'Select labels', + selectionVariant: 'multiple', + }, + argTypes: { + secondaryButtonText: { + name: 'Secondary button text', + type: 'string', + }, + }, +} as Meta + +export const Playground: StoryFn = args => { + const initialSelectedLabels = [data.issue.labelIds[0]] // mock initial state: has selected labels + const [selectedLabelIds, setSelectedLabelIds] = React.useState(initialSelectedLabels) + + /* Selection */ + const onLabelSelect = (labelId: string) => { + if (args.selectionVariant === 'single' || args.selectionVariant === 'instant') { + setSelectedLabelIds([labelId]) + } else { + if (!selectedLabelIds.includes(labelId)) setSelectedLabelIds([...selectedLabelIds, labelId]) + else setSelectedLabelIds(selectedLabelIds.filter(id => id !== labelId)) + } + } + + const onClearSelection = () => { + setSelectedLabelIds([]) + args.onClearSelection() // call storybook action + } + + const onSubmit: SelectPanelProps['onSubmit'] = event => { + data.issue.labelIds = selectedLabelIds // pretending to persist changes + args.onSubmit(event) // call storybook action + } + + const onCancel = () => args.onCancel() // call storybook action + + /* Filtering */ + const [filteredLabels, setFilteredLabels] = React.useState(data.labels) + const [query, setQuery] = React.useState('') + + const onSearchInputChange: React.ChangeEventHandler = event => { + const query = event.currentTarget.value + setQuery(query) + + if (query === '') setFilteredLabels(data.labels) + else { + // Note: in the future, we should probably add a highlight for matching text + setFilteredLabels( + data.labels + .map(label => { + if (label.name.toLowerCase().startsWith(query)) return {priority: 1, label} + else if (label.name.toLowerCase().includes(query)) return {priority: 2, label} + else if (label.description?.toLowerCase().includes(query)) return {priority: 3, label} + else return {priority: -1, label} + }) + .filter(result => result.priority > 0) + .map(result => result.label), + ) + } + } + + const sortingFn = (itemA: {id: string}, itemB: {id: string}) => { + const initialSelectedIds = data.issue.labelIds + if (initialSelectedIds.includes(itemA.id) && initialSelectedIds.includes(itemB.id)) return 1 + else if (initialSelectedIds.includes(itemA.id)) return -1 + else if (initialSelectedIds.includes(itemB.id)) return 1 + else return 1 + } + + const itemsToShow = query ? filteredLabels : data.labels.sort(sortingFn) + + return ( + <> + + Assign label + + + + + + {itemsToShow.length === 0 ? ( + + Try a different search term + + ) : ( + + {itemsToShow.map(label => ( + onLabelSelect(label.id)} + selected={selectedLabelIds.includes(label.id)} + > + + + + {label.name} + {label.description} + + ))} + + )} + + + {args.secondaryButtonText ? ( + {args.secondaryButtonText} + ) : null} + + + + ) +} diff --git a/src/drafts/SelectPanel2/mock-data.ts b/src/drafts/SelectPanel2/stories/mock-data.ts similarity index 99% rename from src/drafts/SelectPanel2/mock-data.ts rename to src/drafts/SelectPanel2/stories/mock-data.ts index 309be3bd23c..75f86fd3c77 100644 --- a/src/drafts/SelectPanel2/mock-data.ts +++ b/src/drafts/SelectPanel2/stories/mock-data.ts @@ -9,11 +9,13 @@ const data = { id: 'MDQ6VXNlcjQxNzI2OA==', name: 'Pavithra Kodmad', login: 'pksjce', + recommended: true, }, { id: 'MDQ6VXNlcjE0NDY1MDM=', name: 'ArmaÄŸan', login: 'broccolinisoup', + recommended: true, }, { id: 'MDQ6VXNlcjE4NjM3NzE=', @@ -29,6 +31,7 @@ const data = { id: 'MDQ6VXNlcjM5MDE3NjQ=', name: 'Josh Black', login: 'joshblack', + recommended: true, }, { id: 'MDQ6VXNlcjE4NjYxMDMw',