From 6a12091692aea3cacef0415069717a4eee273ddf Mon Sep 17 00:00:00 2001 From: Siddharth Kshetrapal Date: Tue, 7 Nov 2023 15:52:07 +0100 Subject: [PATCH 01/37] breaking changes + cleanup --- .../SelectPanel2/SelectPanel.stories.tsx | 125 +----------------- src/drafts/SelectPanel2/SelectPanel.tsx | 4 +- 2 files changed, 9 insertions(+), 120 deletions(-) diff --git a/src/drafts/SelectPanel2/SelectPanel.stories.tsx b/src/drafts/SelectPanel2/SelectPanel.stories.tsx index 1203a3db2af..fdd88f8910e 100644 --- a/src/drafts/SelectPanel2/SelectPanel.stories.tsx +++ b/src/drafts/SelectPanel2/SelectPanel.stories.tsx @@ -362,61 +362,6 @@ export const DAsyncSearchWithUseTransition = () => { ) } -export const TODO1Uncontrolled = () => { - /* features to implement: - 1. search - 2. sort - 3. selection - 4. clear selection - 5. different results view - 6. submit -> pass data / pull from form - 8. cancel callback - 9. empty state - */ - - const onSubmit = () => { - // TODO: where does saved data come from? - // data.issue.labelIds = selectedLabelIds // pretending to persist changes - - // eslint-disable-next-line no-console - console.log('form submitted') - } - - const onCancel = () => { - // eslint-disable-next-line no-console - console.log('panel was closed') - } - - return ( - <> -

Does not work yet: Uncontrolled SelectPanel

- - - {/* @ts-ignore todo */} - Assign label - - - - - - - {data.labels.map(label => ( - - {getCircle(label.color)} - {label.name} - {label.description} - - ))} - - - - Edit labels - - - - ) -} - export const TODO2SingleSelection = () =>

TODO

export const HWithFilterButtons = () => { @@ -479,27 +424,29 @@ export const HWithFilterButtons = () => { {/* TODO: the ref types don't match here, use useProvidedRefOrCreate */} {/* @ts-ignore todo */} - + {savedInitialRef} - + @@ -781,64 +728,6 @@ export const GOpenFromMenu = () => { ) } -export const IWithRemoveFilterIcon = () => { - 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 onClearSelection = () => { - setSelectedLabelIds([]) - } - - 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

- - - {/* TODO: the ref types don't match here, use useProvidedRefOrCreate */} - {/* @ts-ignore todo */} - Assign label - - - {itemsToShow.map(label => ( - onLabelSelect(label.id)} - selected={selectedLabelIds.includes(label.id)} - > - {getCircle(label.color)} - {label.name} - {label.description} - - ))} - - - - ) -} - export const FInstantSelectionVariant = () => { const [selectedTag, setSelectedTag] = React.useState() @@ -855,7 +744,7 @@ export const FInstantSelectionVariant = () => { {/* @ts-ignore todo */} - {selectedTag || 'Choose a tag'} + {selectedTag || 'Choose a tag'} {itemsToShow.map(tag => ( diff --git a/src/drafts/SelectPanel2/SelectPanel.tsx b/src/drafts/SelectPanel2/SelectPanel.tsx index 60f88d2d37b..37d1db91e4a 100644 --- a/src/drafts/SelectPanel2/SelectPanel.tsx +++ b/src/drafts/SelectPanel2/SelectPanel.tsx @@ -207,14 +207,14 @@ const SelectPanelHeader: React.FC = ({children, ...prop display: 'flex', justifyContent: 'space-between', alignItems: description ? 'start' : 'center', - marginBottom: 2, + marginBottom: slots.searchInput ? 2 : 0, }} > {/* heading element is intentionally hardcoded to h1, it is not customisable see https://github.com/github/primer/issues/2578 for context */} - + {title} {description ? ( From f58420416c369372560aa8762b5e0318fa2aaae8 Mon Sep 17 00:00:00 2001 From: Siddharth Kshetrapal Date: Tue, 7 Nov 2023 16:06:06 +0100 Subject: [PATCH 02/37] add SelectPanel.Warning --- .../SelectPanel2/SelectPanel.stories.tsx | 116 ++++++++++++++++++ src/drafts/SelectPanel2/SelectPanel.tsx | 28 ++++- 2 files changed, 143 insertions(+), 1 deletion(-) diff --git a/src/drafts/SelectPanel2/SelectPanel.stories.tsx b/src/drafts/SelectPanel2/SelectPanel.stories.tsx index fdd88f8910e..03cefc2cb19 100644 --- a/src/drafts/SelectPanel2/SelectPanel.stories.tsx +++ b/src/drafts/SelectPanel2/SelectPanel.stories.tsx @@ -761,6 +761,122 @@ export const FInstantSelectionVariant = () => { ) } +export const HWithWarning = () => { + const initialSelectedLabels = data.issue.labelIds // mock initial state: has selected labels + const [selectedLabelIds, setSelectedLabelIds] = React.useState(initialSelectedLabels) + + /* Selection */ + const MAX_LIMIT = 3 + + const onLabelSelect = (labelId: string) => { + if (!selectedLabelIds.includes(labelId)) setSelectedLabelIds([...selectedLabelIds, labelId]) + 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 + + // eslint-disable-next-line no-console + console.log('form submitted') + } + + /* Filtering */ + const [filteredLabels, setFilteredLabels] = React.useState(data.labels) + const [query, setQuery] = React.useState('') + + // TODO: should this be baked-in + const onSearchInputChange = (event: React.KeyboardEvent) => { + const query = event.currentTarget.value + setQuery(query) + + if (query === '') setFilteredLabels(data.labels) + else { + // TODO: 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 ( + <> +

SelectPanel with warning

+ + { + // @ts-ignore todo + onClearSelection(event) + }} + > + {/* TODO: the ref types don't match here, use useProvidedRefOrCreate */} + {/* @ts-ignore todo */} + Assign label + {/* TODO: header and heading is confusing. maybe skip header completely. */} + + + + + {selectedLabelIds.length >= MAX_LIMIT ? ( + You've reached the maximum selection of {MAX_LIMIT} labels + ) : ( + <> + )} + + {itemsToShow.length === 0 ? ( + No labels found for "{query}" + ) : ( + + {itemsToShow.map(label => ( + onLabelSelect(label.id)} + selected={selectedLabelIds.includes(label.id)} + disabled={selectedLabelIds.length >= MAX_LIMIT && !selectedLabelIds.includes(label.id)} + > + {getCircle(label.color)} + {label.name} + {label.description} + + ))} + + )} + + + Edit labels + + + + ) +} + export const TODO3WithValidation = () => { const initialSelectedLabels = data.issue.labelIds // mock initial state: has selected labels const [selectedLabelIds, setSelectedLabelIds] = React.useState(initialSelectedLabels) diff --git a/src/drafts/SelectPanel2/SelectPanel.tsx b/src/drafts/SelectPanel2/SelectPanel.tsx index 37d1db91e4a..65eca049e1f 100644 --- a/src/drafts/SelectPanel2/SelectPanel.tsx +++ b/src/drafts/SelectPanel2/SelectPanel.tsx @@ -1,5 +1,5 @@ import React from 'react' -import {SearchIcon, XCircleFillIcon, XIcon, FilterRemoveIcon} from '@primer/octicons-react' +import {SearchIcon, XCircleFillIcon, XIcon, FilterRemoveIcon, AlertIcon} from '@primer/octicons-react' import {FocusKeys} from '@primer/behaviors' import { @@ -360,6 +360,32 @@ const SelectPanelLoading: React.FC<{children: string}> = ({children = 'Fetching SelectPanel.Loading = SelectPanelLoading +const SelectPanelWarning: React.FC<{children: React.ReactNode}> = ({children}) => { + return ( + + + + + {children} + + ) +} + +SelectPanel.Warning = SelectPanelWarning + const SelectPanelEmptyMessage: React.FC<{children: string | React.ReactNode}> = ({children = 'No items found...'}) => { return ( Date: Tue, 7 Nov 2023 16:22:20 +0100 Subject: [PATCH 03/37] add link styles --- src/drafts/SelectPanel2/SelectPanel.stories.tsx | 7 +++++-- src/drafts/SelectPanel2/SelectPanel.tsx | 1 + 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/drafts/SelectPanel2/SelectPanel.stories.tsx b/src/drafts/SelectPanel2/SelectPanel.stories.tsx index 03cefc2cb19..f6028debcf5 100644 --- a/src/drafts/SelectPanel2/SelectPanel.stories.tsx +++ b/src/drafts/SelectPanel2/SelectPanel.stories.tsx @@ -1,6 +1,6 @@ import React from 'react' import {SelectPanel} from './SelectPanel' -import {ActionList, ActionMenu, Avatar, Box, Button, Flash} from '../../../src/index' +import {ActionList, ActionMenu, Avatar, Box, Button, Flash, Link} from '../../../src/index' import {ArrowRightIcon, AlertIcon, EyeIcon, GitBranchIcon, TriangleDownIcon, TagIcon} from '@primer/octicons-react' import data from './mock-data' @@ -845,7 +845,10 @@ export const HWithWarning = () => { {selectedLabelIds.length >= MAX_LIMIT ? ( - You've reached the maximum selection of {MAX_LIMIT} labels + + You have reached the limit of {MAX_LIMIT} labels on your Free account.{' '} + Upgrade your account. + ) : ( <> )} diff --git a/src/drafts/SelectPanel2/SelectPanel.tsx b/src/drafts/SelectPanel2/SelectPanel.tsx index 65eca049e1f..02bfc50c444 100644 --- a/src/drafts/SelectPanel2/SelectPanel.tsx +++ b/src/drafts/SelectPanel2/SelectPanel.tsx @@ -374,6 +374,7 @@ const SelectPanelWarning: React.FC<{children: React.ReactNode}> = ({children}) = color: 'attention.fg', borderBottom: '1px solid', borderBottomColor: 'attention.muted', + a: {color: 'inherit', textDecoration: 'underline'}, }} > From 6a30cf3b253fd1ef2bbcf23bd8fecbd333dca4ca Mon Sep 17 00:00:00 2001 From: Siddharth Kshetrapal Date: Tue, 7 Nov 2023 16:23:23 +0100 Subject: [PATCH 04/37] remove todo story :) --- .../SelectPanel2/SelectPanel.stories.tsx | 107 ------------------ 1 file changed, 107 deletions(-) diff --git a/src/drafts/SelectPanel2/SelectPanel.stories.tsx b/src/drafts/SelectPanel2/SelectPanel.stories.tsx index f6028debcf5..09cb2bd0f83 100644 --- a/src/drafts/SelectPanel2/SelectPanel.stories.tsx +++ b/src/drafts/SelectPanel2/SelectPanel.stories.tsx @@ -880,113 +880,6 @@ export const HWithWarning = () => { ) } -export const TODO3WithValidation = () => { - 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 = () => { - // soft set, does not save until submit - setSelectedLabelIds([]) - } - - const onSubmit = () => { - data.issue.labelIds = selectedLabelIds // pretending to persist changes - - // eslint-disable-next-line no-console - console.log('form submitted') - } - - /* Filtering */ - const [filteredLabels, setFilteredLabels] = React.useState(data.labels) - const [query, setQuery] = React.useState('') - - // TODO: should this be baked-in - const onSearchInputChange = (event: React.KeyboardEvent) => { - const query = event.currentTarget.value - setQuery(query) - - if (query === '') setFilteredLabels(data.labels) - else { - // TODO: 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 ( - <> -

SelectPanel with validation

- - { - // @ts-ignore todo - onClearSelection(event) - }} - > - {/* TODO: the ref types don't match here, use useProvidedRefOrCreate */} - {/* @ts-ignore todo */} - Assign label - {/* TODO: header and heading is confusing. maybe skip header completely. */} - - - - - {itemsToShow.length === 0 ? ( - No labels found for "{query}" - ) : ( - - {itemsToShow.map(label => ( - onLabelSelect(label.id)} - selected={selectedLabelIds.includes(label.id)} - > - {getCircle(label.color)} - {label.name} - {label.description} - - ))} - - )} - - - Edit labels - - - - ) -} - // ----- Suspense implementation details ---- const cache = new Map() From 52a360811a81cd928dea7b420151bf7186569ecc Mon Sep 17 00:00:00 2001 From: Siddharth Kshetrapal Date: Tue, 7 Nov 2023 16:25:30 +0100 Subject: [PATCH 05/37] typo --- src/drafts/SelectPanel2/SelectPanel.stories.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/drafts/SelectPanel2/SelectPanel.stories.tsx b/src/drafts/SelectPanel2/SelectPanel.stories.tsx index 09cb2bd0f83..c740665417e 100644 --- a/src/drafts/SelectPanel2/SelectPanel.stories.tsx +++ b/src/drafts/SelectPanel2/SelectPanel.stories.tsx @@ -846,7 +846,7 @@ export const HWithWarning = () => { {selectedLabelIds.length >= MAX_LIMIT ? ( - You have reached the limit of {MAX_LIMIT} labels on your Free account.{' '} + You have reached the limit of {MAX_LIMIT} labels on your free account.{' '} Upgrade your account. ) : ( From 5309eb1735f4e197c6dd7108cb85d381f5d42b85 Mon Sep 17 00:00:00 2001 From: Siddharth Kshetrapal Date: Tue, 7 Nov 2023 16:25:57 +0100 Subject: [PATCH 06/37] I not H --- src/drafts/SelectPanel2/SelectPanel.stories.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/drafts/SelectPanel2/SelectPanel.stories.tsx b/src/drafts/SelectPanel2/SelectPanel.stories.tsx index c740665417e..8dd1a57c050 100644 --- a/src/drafts/SelectPanel2/SelectPanel.stories.tsx +++ b/src/drafts/SelectPanel2/SelectPanel.stories.tsx @@ -761,7 +761,7 @@ export const FInstantSelectionVariant = () => { ) } -export const HWithWarning = () => { +export const IWithWarning = () => { const initialSelectedLabels = data.issue.labelIds // mock initial state: has selected labels const [selectedLabelIds, setSelectedLabelIds] = React.useState(initialSelectedLabels) From 8930146546b629d69095aa27c0092854630f88f6 Mon Sep 17 00:00:00 2001 From: Siddharth Kshetrapal Date: Thu, 9 Nov 2023 15:23:10 +0100 Subject: [PATCH 07/37] change example from labels to assignees --- .../SelectPanel2/SelectPanel.stories.tsx | 97 ++++++++++--------- 1 file changed, 52 insertions(+), 45 deletions(-) diff --git a/src/drafts/SelectPanel2/SelectPanel.stories.tsx b/src/drafts/SelectPanel2/SelectPanel.stories.tsx index 8dd1a57c050..65891072187 100644 --- a/src/drafts/SelectPanel2/SelectPanel.stories.tsx +++ b/src/drafts/SelectPanel2/SelectPanel.stories.tsx @@ -1,7 +1,15 @@ import React from 'react' import {SelectPanel} from './SelectPanel' import {ActionList, ActionMenu, Avatar, Box, Button, Flash, Link} from '../../../src/index' -import {ArrowRightIcon, AlertIcon, EyeIcon, GitBranchIcon, TriangleDownIcon, TagIcon} from '@primer/octicons-react' +import { + ArrowRightIcon, + AlertIcon, + EyeIcon, + GitBranchIcon, + TriangleDownIcon, + TagIcon, + GearIcon, +} from '@primer/octicons-react' import data from './mock-data' const getCircle = (color: string) => ( @@ -762,72 +770,65 @@ export const FInstantSelectionVariant = () => { } export const IWithWarning = () => { - const initialSelectedLabels = data.issue.labelIds // mock initial state: has selected labels - const [selectedLabelIds, setSelectedLabelIds] = React.useState(initialSelectedLabels) - /* Selection */ - const MAX_LIMIT = 3 - const onLabelSelect = (labelId: string) => { - if (!selectedLabelIds.includes(labelId)) setSelectedLabelIds([...selectedLabelIds, labelId]) - else setSelectedLabelIds(selectedLabelIds.filter(id => id !== labelId)) - } + const initialAssigneeIds = data.issue.assigneeIds // mock initial state + const [selectedAssigneeIds, setSelectedAssigneeIds] = React.useState(initialAssigneeIds) + const MAX_LIMIT = 3 - const onClearSelection = () => { - // soft set, does not save until submit - setSelectedLabelIds([]) + 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.labelIds = selectedLabelIds // pretending to persist changes - - // eslint-disable-next-line no-console - console.log('form submitted') + 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('') - // TODO: should this be baked-in const onSearchInputChange = (event: React.KeyboardEvent) => { const query = event.currentTarget.value setQuery(query) - if (query === '') setFilteredLabels(data.labels) + if (query === '') setFilteredUsers(data.collaborators) else { // TODO: 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 ( <>

SelectPanel with warning

{ > {/* TODO: the ref types don't match here, use useProvidedRefOrCreate */} {/* @ts-ignore todo */} - Assign label + + Assignees + {/* TODO: header and heading is confusing. maybe skip header completely. */} - {selectedLabelIds.length >= MAX_LIMIT ? ( + {selectedAssigneeIds.length >= MAX_LIMIT ? ( - You have reached the limit of {MAX_LIMIT} labels on your free account.{' '} + You have reached the limit of {MAX_LIMIT} assignees on your free account.{' '} Upgrade your account. ) : ( @@ -857,24 +864,24 @@ export const IWithWarning = () => { No labels found for "{query}" ) : ( - {itemsToShow.map(label => ( + {itemsToShow.map(collaborator => ( onLabelSelect(label.id)} - selected={selectedLabelIds.includes(label.id)} - disabled={selectedLabelIds.length >= MAX_LIMIT && !selectedLabelIds.includes(label.id)} + key={collaborator.id} + onSelect={() => onCollaboratorSelect(collaborator.id)} + selected={selectedAssigneeIds.includes(collaborator.id)} + disabled={selectedAssigneeIds.length >= MAX_LIMIT && !selectedAssigneeIds.includes(collaborator.id)} > - {getCircle(label.color)} - {label.name} - {label.description} + + + + {collaborator.login} + {collaborator.login} ))} )} - - Edit labels - + ) From 96f4ca064ae91bd31368d3197a7aa6c1a4380733 Mon Sep 17 00:00:00 2001 From: Siddharth Kshetrapal Date: Thu, 9 Nov 2023 17:45:50 +0100 Subject: [PATCH 08/37] 1. make it work.... --- .../SelectPanel2/SelectPanel.stories.tsx | 171 +++++++++++++++++- src/drafts/SelectPanel2/SelectPanel.tsx | 32 +++- 2 files changed, 201 insertions(+), 2 deletions(-) diff --git a/src/drafts/SelectPanel2/SelectPanel.stories.tsx b/src/drafts/SelectPanel2/SelectPanel.stories.tsx index 65891072187..9bc02d1f3d4 100644 --- a/src/drafts/SelectPanel2/SelectPanel.stories.tsx +++ b/src/drafts/SelectPanel2/SelectPanel.stories.tsx @@ -1,6 +1,6 @@ import React from 'react' import {SelectPanel} from './SelectPanel' -import {ActionList, ActionMenu, Avatar, Box, Button, Flash, Link} from '../../../src/index' +import {ActionList, ActionMenu, Avatar, Box, Button, Flash, Link, Text, ToggleSwitch} from '../../../src/index' import { ArrowRightIcon, AlertIcon, @@ -887,6 +887,175 @@ export const IWithWarning = () => { ) } +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 */ + + // if search is broken, only show assignees, not all collaborators + const allCollaborators = searchBroken + ? data.collaborators.filter(c => initialAssigneeIds.includes(c.id)) + : data.collaborators + + const [filteredUsers, setFilteredUsers] = React.useState( + searchBroken ? data.collaborators.filter(c => initialAssigneeIds.includes(c.id)) : data.collaborators, + ) + + const [query, setQuery] = React.useState('') + + const onSearchInputChange = (event: React.KeyboardEvent) => { + 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" + /> + + + + {/* TODO: the ref types don't match here, use useProvidedRefOrCreate */} + {/* @ts-ignore todo */} + + Assignees + + + + + + {issuesBroken ? ( + + We couldn't load collaborators + + 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 ? ( + No labels found for "{query}" + ) : ( + + {itemsToShow.map(collaborator => ( + onCollaboratorSelect(collaborator.id)} + selected={selectedAssigneeIds.includes(collaborator.id)} + > + + + + {collaborator.login} + {collaborator.login} + + ))} + + )} + + )} + + + + + ) +} + // ----- Suspense implementation details ---- const cache = new Map() diff --git a/src/drafts/SelectPanel2/SelectPanel.tsx b/src/drafts/SelectPanel2/SelectPanel.tsx index 02bfc50c444..b40033d84ed 100644 --- a/src/drafts/SelectPanel2/SelectPanel.tsx +++ b/src/drafts/SelectPanel2/SelectPanel.tsx @@ -14,11 +14,13 @@ import { Spinner, Text, ActionListProps, + Octicon, } from '../../../src/index' import {ActionListContainerContext} from '../../../src/ActionList/ActionListContainerContext' import {useSlots} from '../../hooks/useSlots' import {useProvidedRefOrCreate, useId} from '../../hooks' import {useFocusZone} from '../../hooks/useFocusZone' +import {BetterSystemStyleObject} from '../../sx' const SelectPanelContext = React.createContext<{ title: string @@ -360,7 +362,10 @@ const SelectPanelLoading: React.FC<{children: string}> = ({children = 'Fetching SelectPanel.Loading = SelectPanelLoading -const SelectPanelWarning: React.FC<{children: React.ReactNode}> = ({children}) => { +const SelectPanelWarning: React.FC<{children: React.ReactNode; sx?: BetterSystemStyleObject}> = ({ + children, + sx = {}, +}) => { return ( = ({children}) = borderBottom: '1px solid', borderBottomColor: 'attention.muted', a: {color: 'inherit', textDecoration: 'underline'}, + ...sx, // TODO: deep merge! }} > @@ -408,6 +414,30 @@ const SelectPanelEmptyMessage: React.FC<{children: string | React.ReactNode}> = SelectPanel.EmptyMessage = SelectPanelEmptyMessage +const SelectPanelErrorMessage: React.FC<{children: string | React.ReactNode}> = ({children = 'No items found...'}) => { + return ( + + + {children} + + ) +} + +SelectPanel.ErrorMessage = SelectPanelErrorMessage + export {SelectPanel} // This is probably a horrible idea and we would not ship this... From 4393e0ce726d8963eb9410e102280a18df747ca6 Mon Sep 17 00:00:00 2001 From: Siddharth Kshetrapal Date: Fri, 10 Nov 2023 11:53:59 +0100 Subject: [PATCH 09/37] =?UTF-8?q?move=20implementation=20details=20from=20?= =?UTF-8?q?story=20=E2=86=92=20component?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SelectPanel2/SelectPanel.stories.tsx | 13 ++++--------- src/drafts/SelectPanel2/SelectPanel.tsx | 18 +++++++++++++++--- 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/src/drafts/SelectPanel2/SelectPanel.stories.tsx b/src/drafts/SelectPanel2/SelectPanel.stories.tsx index 9bc02d1f3d4..2cdfc51b0d2 100644 --- a/src/drafts/SelectPanel2/SelectPanel.stories.tsx +++ b/src/drafts/SelectPanel2/SelectPanel.stories.tsx @@ -1012,21 +1012,16 @@ export const JWithErrors = () => { {issuesBroken ? ( - - We couldn't load collaborators - - Try again or if the problem persists, contact support - + + 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 ? ( No labels found for "{query}" diff --git a/src/drafts/SelectPanel2/SelectPanel.tsx b/src/drafts/SelectPanel2/SelectPanel.tsx index b40033d84ed..0d52ef82e6d 100644 --- a/src/drafts/SelectPanel2/SelectPanel.tsx +++ b/src/drafts/SelectPanel2/SelectPanel.tsx @@ -393,7 +393,7 @@ const SelectPanelWarning: React.FC<{children: React.ReactNode; sx?: BetterSystem SelectPanel.Warning = SelectPanelWarning -const SelectPanelEmptyMessage: React.FC<{children: string | React.ReactNode}> = ({children = 'No items found...'}) => { +const SelectPanelEmptyMessage: React.FC<{children: React.ReactNode}> = ({children = 'No items found...'}) => { return ( = SelectPanel.EmptyMessage = SelectPanelEmptyMessage -const SelectPanelErrorMessage: React.FC<{children: string | React.ReactNode}> = ({children = 'No items found...'}) => { +const SelectPanelErrorMessage: React.FC<{title: string; children: React.ReactNode}> = ({title, children}) => { return ( = }} > - {children} + {title} + {children} ) } SelectPanel.ErrorMessage = SelectPanelErrorMessage +const SelectPanelInlineErrorMessage: React.FC<{children: React.ReactNode}> = props => { + return ( + + ) +} + +SelectPanel.InlineErrorMessage = SelectPanelInlineErrorMessage + export {SelectPanel} // This is probably a horrible idea and we would not ship this... From d42096eabdb0af6dee49653876bc9d6e36a9ad32 Mon Sep 17 00:00:00 2001 From: Siddharth Kshetrapal Date: Fri, 10 Nov 2023 12:06:39 +0100 Subject: [PATCH 10/37] absorb EmptyMessage into SelectPanel.Message --- .../SelectPanel2/SelectPanel.stories.tsx | 24 ++++++-- src/drafts/SelectPanel2/SelectPanel.tsx | 57 ++++++++++++------- 2 files changed, 54 insertions(+), 27 deletions(-) diff --git a/src/drafts/SelectPanel2/SelectPanel.stories.tsx b/src/drafts/SelectPanel2/SelectPanel.stories.tsx index 2cdfc51b0d2..c655b9f2a4b 100644 --- a/src/drafts/SelectPanel2/SelectPanel.stories.tsx +++ b/src/drafts/SelectPanel2/SelectPanel.stories.tsx @@ -107,7 +107,9 @@ export const AControlled = () => { {itemsToShow.length === 0 ? ( - No labels found for "{query}" + + Try a different search term + ) : ( {itemsToShow.map(label => ( @@ -197,7 +199,9 @@ const SuspendedActionList: React.FC<{query: string}> = ({query}) => { const itemsToShow = query ? filteredLabels : data.labels.sort(sortingFn) return itemsToShow.length === 0 ? ( - No labels found for "{query}" + + Try a different search term + ) : ( {itemsToShow.map(label => ( @@ -296,7 +300,9 @@ const SearchableUserList: React.FC<{ const itemsToShow = query ? filteredUsers : repository.collaborators.sort(sortingFn) return itemsToShow.length === 0 ? ( - No users found for "{query}" + + Try a different search term + ) : ( {itemsToShow.map(user => ( @@ -460,7 +466,9 @@ export const HWithFilterButtons = () => { {itemsToShow.length === 0 ? ( - No labels found for "{'query'}" + + Try a different search term + ) : ( {itemsToShow.map(item => ( @@ -861,7 +869,9 @@ export const IWithWarning = () => { )} {itemsToShow.length === 0 ? ( - No labels found for "{query}" + + Try a different search term + ) : ( {itemsToShow.map(collaborator => ( @@ -1024,7 +1034,9 @@ export const JWithErrors = () => { ) : null} {itemsToShow.length === 0 ? ( - No labels found for "{query}" + + Try a different search term + ) : ( {itemsToShow.map(collaborator => ( diff --git a/src/drafts/SelectPanel2/SelectPanel.tsx b/src/drafts/SelectPanel2/SelectPanel.tsx index 0d52ef82e6d..4f9ae4188e6 100644 --- a/src/drafts/SelectPanel2/SelectPanel.tsx +++ b/src/drafts/SelectPanel2/SelectPanel.tsx @@ -393,27 +393,6 @@ const SelectPanelWarning: React.FC<{children: React.ReactNode; sx?: BetterSystem SelectPanel.Warning = SelectPanelWarning -const SelectPanelEmptyMessage: React.FC<{children: React.ReactNode}> = ({children = 'No items found...'}) => { - return ( - - {children} - Try a different search term - - ) -} - -SelectPanel.EmptyMessage = SelectPanelEmptyMessage - const SelectPanelErrorMessage: React.FC<{title: string; children: React.ReactNode}> = ({title, children}) => { return ( = pro SelectPanel.InlineErrorMessage = SelectPanelInlineErrorMessage +// TODO: variant: 'empty' and size: 'inline' is not possible combination +const SelectPanelMessage: React.FC<{ + variant: 'warning' | 'error' | 'empty' + size?: 'full' | 'inline' + title?: string + children: React.ReactNode +}> = ({variant = 'warning', size = 'full', title, children}) => { + if (size === 'full') { + return ( + + {variant === 'error' ? : null} + {title} + {children} + + ) + } else { + // todo + return null + } +} + +SelectPanel.Message = SelectPanelMessage + export {SelectPanel} // This is probably a horrible idea and we would not ship this... From cda8096737fa27bfaa32b163f4baabc00a253b3b Mon Sep 17 00:00:00 2001 From: Siddharth Kshetrapal Date: Fri, 10 Nov 2023 12:16:08 +0100 Subject: [PATCH 11/37] absorb WarningMessage into SelectPanel.Message --- .../SelectPanel2/SelectPanel.stories.tsx | 8 +-- src/drafts/SelectPanel2/SelectPanel.tsx | 65 +++++++++---------- 2 files changed, 34 insertions(+), 39 deletions(-) diff --git a/src/drafts/SelectPanel2/SelectPanel.stories.tsx b/src/drafts/SelectPanel2/SelectPanel.stories.tsx index c655b9f2a4b..76151a0abbe 100644 --- a/src/drafts/SelectPanel2/SelectPanel.stories.tsx +++ b/src/drafts/SelectPanel2/SelectPanel.stories.tsx @@ -860,13 +860,11 @@ export const IWithWarning = () => { {selectedAssigneeIds.length >= MAX_LIMIT ? ( - + You have reached the limit of {MAX_LIMIT} assignees on your free account.{' '} Upgrade your account. - - ) : ( - <> - )} + + ) : null} {itemsToShow.length === 0 ? ( diff --git a/src/drafts/SelectPanel2/SelectPanel.tsx b/src/drafts/SelectPanel2/SelectPanel.tsx index 4f9ae4188e6..e69405a20d6 100644 --- a/src/drafts/SelectPanel2/SelectPanel.tsx +++ b/src/drafts/SelectPanel2/SelectPanel.tsx @@ -362,37 +362,6 @@ const SelectPanelLoading: React.FC<{children: string}> = ({children = 'Fetching SelectPanel.Loading = SelectPanelLoading -const SelectPanelWarning: React.FC<{children: React.ReactNode; sx?: BetterSystemStyleObject}> = ({ - children, - sx = {}, -}) => { - return ( - - - - - {children} - - ) -} - -SelectPanel.Warning = SelectPanelWarning - const SelectPanelErrorMessage: React.FC<{title: string; children: React.ReactNode}> = ({title, children}) => { return ( = ({variant = 'warning', size = 'full', title, children}) => { + const variantStyles = { + empty: {}, + warning: { + backgroundColor: 'attention.subtle', + color: 'attention.fg', + borderBottomColor: 'attention.muted', + }, + error: {}, + } + if (size === 'full') { return ( {variant === 'error' ? : null} @@ -458,8 +439,24 @@ const SelectPanelMessage: React.FC<{ ) } else { - // todo - return null + return ( + + + {children} + + ) } } From 310fefe25ed32fecaf1ead5f63b0427a5500b80c Mon Sep 17 00:00:00 2001 From: Siddharth Kshetrapal Date: Fri, 10 Nov 2023 12:26:12 +0100 Subject: [PATCH 12/37] absorb ErrorMessage into SelectPanel.Message --- .../SelectPanel2/SelectPanel.stories.tsx | 8 +-- src/drafts/SelectPanel2/SelectPanel.tsx | 71 ++++++------------- 2 files changed, 24 insertions(+), 55 deletions(-) diff --git a/src/drafts/SelectPanel2/SelectPanel.stories.tsx b/src/drafts/SelectPanel2/SelectPanel.stories.tsx index 76151a0abbe..42d36adaf4f 100644 --- a/src/drafts/SelectPanel2/SelectPanel.stories.tsx +++ b/src/drafts/SelectPanel2/SelectPanel.stories.tsx @@ -1020,16 +1020,16 @@ export const JWithErrors = () => { {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 ? ( diff --git a/src/drafts/SelectPanel2/SelectPanel.tsx b/src/drafts/SelectPanel2/SelectPanel.tsx index e69405a20d6..d956791d3ae 100644 --- a/src/drafts/SelectPanel2/SelectPanel.tsx +++ b/src/drafts/SelectPanel2/SelectPanel.tsx @@ -362,59 +362,13 @@ const SelectPanelLoading: React.FC<{children: string}> = ({children = 'Fetching SelectPanel.Loading = SelectPanelLoading -const SelectPanelErrorMessage: React.FC<{title: string; children: React.ReactNode}> = ({title, children}) => { - return ( - - - {title} - {children} - - ) -} - -SelectPanel.ErrorMessage = SelectPanelErrorMessage - -const SelectPanelInlineErrorMessage: React.FC<{children: React.ReactNode}> = props => { - return ( - - ) -} - -SelectPanel.InlineErrorMessage = SelectPanelInlineErrorMessage - // TODO: variant: 'empty' and size: 'inline' is not possible combination const SelectPanelMessage: React.FC<{ variant: 'warning' | 'error' | 'empty' - size?: 'full' | 'inline' // TODO: is this the variant and the other is level? + size?: 'full' | 'inline' title?: string children: React.ReactNode -}> = ({variant = 'warning', size = 'full', title, children}) => { - const variantStyles = { - empty: {}, - warning: { - backgroundColor: 'attention.subtle', - color: 'attention.fg', - borderBottomColor: 'attention.muted', - }, - error: {}, - } - +}> = ({variant = 'warning', size = variant === 'empty' ? 'full' : 'inline', title, children}) => { if (size === 'full') { return ( - {variant === 'error' ? : null} + {variant !== 'empty' ? ( + + ) : null} {title} {children} ) } else { + const inlineVariantStyles = { + empty: {}, + warning: { + backgroundColor: 'attention.subtle', + color: 'attention.fg', + borderBottomColor: 'attention.muted', + }, + error: { + backgroundColor: 'danger.subtle', + color: 'danger.fg', + borderColor: 'danger.muted', + }, + } + return ( From 336b42ebeeda21d95f5cd2a3172b7a2d6a911dc4 Mon Sep 17 00:00:00 2001 From: Siddharth Kshetrapal Date: Fri, 10 Nov 2023 12:30:18 +0100 Subject: [PATCH 13/37] stricter types for empty --- src/drafts/SelectPanel2/SelectPanel.tsx | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/drafts/SelectPanel2/SelectPanel.tsx b/src/drafts/SelectPanel2/SelectPanel.tsx index d956791d3ae..3fa38f819fa 100644 --- a/src/drafts/SelectPanel2/SelectPanel.tsx +++ b/src/drafts/SelectPanel2/SelectPanel.tsx @@ -362,13 +362,17 @@ const SelectPanelLoading: React.FC<{children: string}> = ({children = 'Fetching SelectPanel.Loading = SelectPanelLoading -// TODO: variant: 'empty' and size: 'inline' is not possible combination -const SelectPanelMessage: React.FC<{ - variant: 'warning' | 'error' | 'empty' - size?: 'full' | 'inline' - title?: string - children: React.ReactNode -}> = ({variant = 'warning', size = variant === 'empty' ? 'full' : 'inline', title, children}) => { +type SelectPanelMessageProps = {title?: string; children: React.ReactNode} & ( + | {variant: 'empty'; size?: 'full'} // empty + inline = invalid combination + | {variant: 'warning' | 'error'; size?: 'inline' | 'full'} +) + +const SelectPanelMessage: React.FC = ({ + variant = 'warning', + size = variant === 'empty' ? 'full' : 'inline', + title, + children, +}) => { if (size === 'full') { return ( Date: Fri, 10 Nov 2023 12:30:53 +0100 Subject: [PATCH 14/37] clean up lint errors --- src/drafts/SelectPanel2/SelectPanel.stories.tsx | 6 ------ src/drafts/SelectPanel2/SelectPanel.tsx | 1 - 2 files changed, 7 deletions(-) diff --git a/src/drafts/SelectPanel2/SelectPanel.stories.tsx b/src/drafts/SelectPanel2/SelectPanel.stories.tsx index 42d36adaf4f..9c799aa24e5 100644 --- a/src/drafts/SelectPanel2/SelectPanel.stories.tsx +++ b/src/drafts/SelectPanel2/SelectPanel.stories.tsx @@ -914,12 +914,6 @@ export const JWithErrors = () => { } /* Filtering */ - - // if search is broken, only show assignees, not all collaborators - const allCollaborators = searchBroken - ? data.collaborators.filter(c => initialAssigneeIds.includes(c.id)) - : data.collaborators - const [filteredUsers, setFilteredUsers] = React.useState( searchBroken ? data.collaborators.filter(c => initialAssigneeIds.includes(c.id)) : data.collaborators, ) diff --git a/src/drafts/SelectPanel2/SelectPanel.tsx b/src/drafts/SelectPanel2/SelectPanel.tsx index 3fa38f819fa..887a97ca76d 100644 --- a/src/drafts/SelectPanel2/SelectPanel.tsx +++ b/src/drafts/SelectPanel2/SelectPanel.tsx @@ -20,7 +20,6 @@ import {ActionListContainerContext} from '../../../src/ActionList/ActionListCont import {useSlots} from '../../hooks/useSlots' import {useProvidedRefOrCreate, useId} from '../../hooks' import {useFocusZone} from '../../hooks/useFocusZone' -import {BetterSystemStyleObject} from '../../sx' const SelectPanelContext = React.createContext<{ title: string From 24127c1db51146c128997a774e9151c725d063a1 Mon Sep 17 00:00:00 2001 From: Siddharth Kshetrapal Date: Fri, 10 Nov 2023 12:47:20 +0100 Subject: [PATCH 15/37] tiny cleanup for bug --- src/drafts/SelectPanel2/SelectPanel.stories.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/drafts/SelectPanel2/SelectPanel.stories.tsx b/src/drafts/SelectPanel2/SelectPanel.stories.tsx index 9c799aa24e5..e3a8b17e326 100644 --- a/src/drafts/SelectPanel2/SelectPanel.stories.tsx +++ b/src/drafts/SelectPanel2/SelectPanel.stories.tsx @@ -864,7 +864,9 @@ export const IWithWarning = () => { You have reached the limit of {MAX_LIMIT} assignees on your free account.{' '} Upgrade your account. - ) : null} + ) : ( + <> + )} {itemsToShow.length === 0 ? ( From db5359f0afded72abc4d901c322b924936ca9c15 Mon Sep 17 00:00:00 2001 From: Siddharth Kshetrapal Date: Fri, 10 Nov 2023 13:14:42 +0100 Subject: [PATCH 16/37] clean up message types real good! --- src/drafts/SelectPanel2/SelectPanel.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/drafts/SelectPanel2/SelectPanel.tsx b/src/drafts/SelectPanel2/SelectPanel.tsx index 887a97ca76d..bc559a25f13 100644 --- a/src/drafts/SelectPanel2/SelectPanel.tsx +++ b/src/drafts/SelectPanel2/SelectPanel.tsx @@ -361,9 +361,17 @@ const SelectPanelLoading: React.FC<{children: string}> = ({children = 'Fetching SelectPanel.Loading = SelectPanelLoading -type SelectPanelMessageProps = {title?: string; children: React.ReactNode} & ( - | {variant: 'empty'; size?: 'full'} // empty + inline = invalid combination - | {variant: 'warning' | 'error'; size?: 'inline' | 'full'} +type SelectPanelMessageProps = {children: React.ReactNode} & ( + | { + size?: 'full' + title: string // title is required with size:full + variant: 'warning' | 'error' | 'empty' // default: warning + } + | { + size?: 'inline' + title?: never // title is invalid with size:inline + variant: 'warning' | 'error' // variant:empty + size:inline = invalid combination + } ) const SelectPanelMessage: React.FC = ({ From 68af462ae037ac60c19bf5a9d91c156e2752086b Mon Sep 17 00:00:00 2001 From: Siddharth Kshetrapal Date: Fri, 10 Nov 2023 13:33:49 +0100 Subject: [PATCH 17/37] child can be null --- src/drafts/SelectPanel2/SelectPanel.stories.tsx | 4 +--- src/drafts/SelectPanel2/SelectPanel.tsx | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/drafts/SelectPanel2/SelectPanel.stories.tsx b/src/drafts/SelectPanel2/SelectPanel.stories.tsx index e3a8b17e326..9c799aa24e5 100644 --- a/src/drafts/SelectPanel2/SelectPanel.stories.tsx +++ b/src/drafts/SelectPanel2/SelectPanel.stories.tsx @@ -864,9 +864,7 @@ export const IWithWarning = () => { You have reached the limit of {MAX_LIMIT} assignees on your free account.{' '} Upgrade your account. - ) : ( - <> - )} + ) : null} {itemsToShow.length === 0 ? ( diff --git a/src/drafts/SelectPanel2/SelectPanel.tsx b/src/drafts/SelectPanel2/SelectPanel.tsx index bc559a25f13..a719c80e945 100644 --- a/src/drafts/SelectPanel2/SelectPanel.tsx +++ b/src/drafts/SelectPanel2/SelectPanel.tsx @@ -50,7 +50,7 @@ const SelectPanel = props => { // with additional props for accessibility let renderAnchor: AnchoredOverlayProps['renderAnchor'] = null const contents = React.Children.map(props.children, child => { - if (child.type === SelectPanelButton) { + if (child?.type === SelectPanelButton) { renderAnchor = anchorProps => React.cloneElement(child, anchorProps) return null } From bd44d73c320fa40c319b770d7254c60a10fa4a91 Mon Sep 17 00:00:00 2001 From: Siddharth Kshetrapal Date: Fri, 10 Nov 2023 14:00:49 +0100 Subject: [PATCH 18/37] types: first round --- src/drafts/SelectPanel2/SelectPanel.tsx | 126 ++++++++++++++---------- 1 file changed, 72 insertions(+), 54 deletions(-) diff --git a/src/drafts/SelectPanel2/SelectPanel.tsx b/src/drafts/SelectPanel2/SelectPanel.tsx index a719c80e945..9ddd6b70289 100644 --- a/src/drafts/SelectPanel2/SelectPanel.tsx +++ b/src/drafts/SelectPanel2/SelectPanel.tsx @@ -23,7 +23,7 @@ import {useFocusZone} from '../../hooks/useFocusZone' const SelectPanelContext = React.createContext<{ title: string - description: string + description?: string panelId: string onCancel: () => void onClearSelection: undefined | (() => void) @@ -32,7 +32,7 @@ const SelectPanelContext = React.createContext<{ selectionVariant: ActionListProps['selectionVariant'] | 'instant' }>({ title: '', - description: '', + description: undefined, panelId: '', onCancel: () => {}, onClearSelection: undefined, @@ -41,50 +41,88 @@ const SelectPanelContext = React.createContext<{ selectionVariant: 'multiple', }) +export type SelectPanelProps = { + title: string + description?: string + selectionVariant?: ActionListProps['selectionVariant'] | 'instant' + id?: string + + defaultOpen?: boolean + open?: boolean + anchorRef?: React.RefObject + + onCancel?: () => void + onClearSelection?: undefined | (() => void) + onSubmit?: (event?: React.FormEvent) => void + + // TODO: move these to SelectPanel.Overlay or overlayProps + width?: AnchoredOverlayProps['width'] + height?: AnchoredOverlayProps['height'] + + children: React.ReactNode +} + // @ts-ignore todo -const SelectPanel = props => { - const anchorRef = useProvidedRefOrCreate(props.anchorRef) +const Panel: React.FC = ({ + title, + description, + selectionVariant = 'multiple', + id, + + defaultOpen = false, + open: propsOpen, + anchorRef: providedAnchorRef, + + onCancel: propsOnCancel, + onClearSelection: propsOnClearSelection, + onSubmit: propsOnSubmit, + + width = 'medium', + height = 'large', + ...props +}) => { + const anchorRef = useProvidedRefOrCreate(providedAnchorRef) // 🚨 Hack for good API! // we strip out Anchor from children and pass it to AnchoredOverlay to render // with additional props for accessibility let renderAnchor: AnchoredOverlayProps['renderAnchor'] = null const contents = React.Children.map(props.children, child => { - if (child?.type === SelectPanelButton) { + if (React.isValidElement(child) && child.type === SelectPanelButton) { renderAnchor = anchorProps => React.cloneElement(child, anchorProps) return null } return child }) - const [internalOpen, setInternalOpen] = React.useState(props.defaultOpen) + const [internalOpen, setInternalOpen] = React.useState(defaultOpen) // sync open state - React.useEffect(() => setInternalOpen(props.open), [props.open]) + React.useEffect(() => setInternalOpen(propsOpen || false), [propsOpen]) const onInternalClose = () => { - if (props.open === undefined) setInternalOpen(false) - if (typeof props.onCancel === 'function') props.onCancel() + if (propsOpen === undefined) setInternalOpen(false) + if (typeof propsOnCancel === 'function') propsOnCancel() } - const onInternalSubmit = (event?: React.SyntheticEvent) => { + const onInternalSubmit = (event?: React.FormEvent) => { event?.preventDefault() // there is no event with selectionVariant=instant - if (props.open === undefined) setInternalOpen(false) - if (typeof props.onSubmit === 'function') props.onSubmit(event) + if (propsOpen === undefined) setInternalOpen(false) + if (typeof propsOnSubmit === 'function') propsOnSubmit(event) } const onInternalClearSelection = () => { - if (typeof props.onSubmit === 'function') props.onClearSelection() + if (typeof propsOnClearSelection === 'function') propsOnClearSelection() } const internalAfterSelect = () => { - if (props.selectionVariant === 'instant') onInternalSubmit() + if (selectionVariant === 'instant') onInternalSubmit() } /* Search/Filter */ const [searchQuery, setSearchQuery] = React.useState('') /* Panel plumbing */ - const panelId = useId(props.id) + const panelId = useId(id) const [slots, childrenInBody] = useSlots(contents, {header: SelectPanelHeader, footer: SelectPanelFooter}) /* Arrow keys navigation for list items */ @@ -105,8 +143,8 @@ const SelectPanel = props => { open={internalOpen} onOpen={() => setInternalOpen(true)} onClose={onInternalClose} - width={props.width || 'medium'} - height={props.height || 'large'} + width={width} + height={height} focusZoneSettings={{ // we only want focus trap from the overlay, // we don't want focus zone on the whole overlay because @@ -116,20 +154,20 @@ const SelectPanel = props => { overlayProps={{ role: 'dialog', 'aria-labelledby': `${panelId}--title`, - 'aria-describedby': props.description ? `${panelId}--description` : undefined, + 'aria-describedby': description ? `${panelId}--description` : undefined, }} > { }} > {/* render default header as fallback */} - {slots.header || } + {slots.header || } } @@ -162,8 +200,7 @@ const SelectPanel = props => { container: 'SelectPanel', listRole: 'listbox', selectionAttribute: 'aria-selected', - selectionVariant: - props.selectionVariant === 'instant' ? 'single' : props.selectionVariant || 'multiple', + selectionVariant: selectionVariant === 'instant' ? 'single' : selectionVariant, afterSelect: internalAfterSelect, }} > @@ -182,7 +219,6 @@ const SelectPanelButton = React.forwardRef((props, anchorRef) => { // @ts-ignore todo return - )} - 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..2fbd66fd2ec --- /dev/null +++ b/src/drafts/SelectPanel2/stories/SelectPanel.default.stories.tsx @@ -0,0 +1,124 @@ +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 = () => { + // soft set, does not save until submit + setSelectedLabelIds([]) + } + + const onSubmit = () => { + data.issue.labelIds = selectedLabelIds // pretending to persist changes + + // eslint-disable-next-line no-console + console.log('form submitted') + } + + /* 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 ( + <> +

Controlled SelectPanel

+ + { + /* 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. */} + + + + + {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 97% rename from src/drafts/SelectPanel2/SelectPanel.stories.tsx rename to src/drafts/SelectPanel2/stories/SelectPanel.examples.stories.tsx index b4dd23b130f..eac3f56507c 100644 --- a/src/drafts/SelectPanel2/SelectPanel.stories.tsx +++ b/src/drafts/SelectPanel2/stories/SelectPanel.examples.stories.tsx @@ -1,6 +1,6 @@ import React from 'react' -import {SelectPanel} from './SelectPanel' -import {ActionList, ActionMenu, Avatar, Box, Button, Flash, Link, Text, ToggleSwitch} from '../../../src/index' +import {SelectPanel} from '../SelectPanel' +import {ActionList, ActionMenu, Avatar, Box, Button, Flash, Link, Text, ToggleSwitch} from '../../../index' import { ArrowRightIcon, AlertIcon, @@ -12,6 +12,11 @@ import { } from '@primer/octicons-react' import data from './mock-data' +export default { + title: 'Drafts/Components/SelectPanel/Examples', + component: SelectPanel, +} + const getCircle = (color: string) => ( ) @@ -1051,24 +1056,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 +1084,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..f133bff01a0 --- /dev/null +++ b/src/drafts/SelectPanel2/stories/SelectPanel.features.stories.tsx @@ -0,0 +1,1086 @@ +import React from 'react' +import {SelectPanel} from '../SelectPanel' +import {ActionList, ActionMenu, Avatar, Box, Button, Flash, Link, Text, ToggleSwitch} from '../../../index' +import { + ArrowRightIcon, + AlertIcon, + EyeIcon, + GitBranchIcon, + TriangleDownIcon, + 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 AControlled = () => { + 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 = () => { + // soft set, does not save until submit + setSelectedLabelIds([]) + } + + const onSubmit = () => { + data.issue.labelIds = selectedLabelIds // pretending to persist changes + + // eslint-disable-next-line no-console + console.log('form submitted') + } + + /* 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 ( + <> +

Controlled SelectPanel

+ + { + /* 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. */} + + + + + {itemsToShow.length === 0 ? ( + + Try a different search term + + ) : ( + + {itemsToShow.map(label => ( + onLabelSelect(label.id)} + selected={selectedLabelIds.includes(label.id)} + > + {getCircle(label.color)} + {label.name} + {label.description} + + ))} + + )} + + + Edit labels + + + + ) +} + +export const BWithSuspendedList = () => { + const [query, setQuery] = React.useState('') + + const onSearchInputChange: React.ChangeEventHandler = event => { + const query = event.currentTarget.value + setQuery(query) + } + + return ( + <> +

Suspended list

+

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

+ + Assign label + + + + + + Fetching labels...}> + + + Edit labels + + + + + ) +} + +const SuspendedActionList: React.FC<{query: string}> = ({query}) => { + const fetchedData: typeof data = use(getData({key: 'suspended-action-list'})) + + /* Selection */ + const initialSelectedLabels: string[] = fetchedData.issue.labelIds + const [selectedLabelIds, setSelectedLabelIds] = React.useState(initialSelectedLabels) + + const onLabelSelect = (labelId: string) => { + if (!selectedLabelIds.includes(labelId)) setSelectedLabelIds([...selectedLabelIds, labelId]) + else setSelectedLabelIds(selectedLabelIds.filter(id => id !== labelId)) + } + + /* Filtering */ + const filteredLabels = fetchedData.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 itemsToShow.length === 0 ? ( + + Try a different search term + + ) : ( + + {itemsToShow.map(label => ( + onLabelSelect(label.id)} + selected={selectedLabelIds.includes(label.id)} + > + {getCircle(label.color)} + {label.name} + {label.description} + + ))} + + ) +} + +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 +*/ +const SearchableUserList: React.FC<{ + query: string + showLoading?: boolean + initialAssigneeIds: string[] + selectedUserIds: string[] + onUserSelect: (id: string) => void +}> = ({query, showLoading = false, initialAssigneeIds, selectedUserIds, onUserSelect}) => { + const repository = {collaborators: data.collaborators} + + /* Filtering */ + const filteredUsers: typeof data.users = query ? use(queryUsers({query})) : [] + + if (showLoading) return Search for users... + + const sortingFn = (itemA: {id: string}, itemB: {id: string}) => { + if (initialAssigneeIds.includes(itemA.id) && initialAssigneeIds.includes(itemB.id)) return 1 + else if (initialAssigneeIds.includes(itemA.id)) return -1 + else if (initialAssigneeIds.includes(itemB.id)) return 1 + else return 1 + } + const itemsToShow = query ? filteredUsers : repository.collaborators.sort(sortingFn) + + return itemsToShow.length === 0 ? ( + + Try a different search term + + ) : ( + + {itemsToShow.map(user => ( + onUserSelect(user.id)} + selected={selectedUserIds.includes(user.id)} + > + + + + {user.login} + {user.name} + + ))} + + ) +} + +/* + `data` is already pre-fetched with the issue + `users` are fetched async on search +*/ +export const DAsyncSearchWithUseTransition = () => { + const [isPending, startTransition] = React.useTransition() + + const [query, setQuery] = React.useState('') + const onSearchInputChange: React.ChangeEventHandler = event => { + const query = event.currentTarget.value + startTransition(() => 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 + + + + + Fetching users...}> + + + + + + ) +} + +export const TODO2SingleSelection = () =>

TODO

+ +export const HWithFilterButtons = () => { + const [selectedFilter, setSelectedFilter] = React.useState<'branches' | 'tags'>('branches') + + /* 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 [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), + ) + } + } + + React.useEffect( + function updateSearchResults() { + setSearchResults(query, selectedFilter) + }, + [query, selectedFilter], + ) + + const sortingFn = (ref: {id: string}) => { + if (ref.id === savedInitialRef) return -1 + else return 1 + } + + const itemsToShow = query ? filteredRefs : data[selectedFilter].sort(sortingFn) + + 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 +

+ + + setMenuOpen(value)}> + + + setSelectedSetting('Participating and @mentions')} + > + Participating and @mentions + + Only receive notifications from this repository when participating or @mentioned. + + + setSelectedSetting('All activity')} + > + All activity + + Notified of all notifications on this repository. + + + setSelectedSetting('Ignore')}> + Ignore + Never be notified. + + setSelectPanelOpen(true)}> + Custom + + + + + Select events you want to be notified of in addition to participating and @mentions. + + + + + + + { + setSelectPanelOpen(false) + onSelectPanelSubmit() + }} + onCancel={() => { + setSelectPanelOpen(false) + setMenuOpen(true) + }} + height="medium" + > + + {itemsToShow.map(item => ( + onEventSelect(item)} selected={selectedEvents.includes(item)}> + {item} + + ))} + + + + + ) +} + +export const FInstantSelectionVariant = () => { + 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 IWithWarning = () => { + /* 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 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} + + ))} + + )} + + )} + + + + + ) +} + +// ----- Suspense implementation details ---- + +const cache = new Map() +const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) + +const getData = ({key = '0', delay = 1000}: {key: string; delay?: number}) => { + if (!cache.has(key)) cache.set(key, fetchData(delay)) + return cache.get(key) +} +// return a promise! +const fetchData = async (delay: number) => { + await sleep(delay) + return data +} + +const queryUsers = ({query = '', delay = 500}: {query: string; delay?: number}) => { + const key = `users-${query}` + if (!cache.has(key)) cache.set(key, fetchUsers(query, delay)) + return cache.get(key) +} +const fetchUsers = async (query: string, delay: number) => { + await sleep(delay) + return data.users.filter(user => { + return ( + user.login.toLowerCase().includes(query.toLowerCase()) || user.name.toLowerCase().includes(query.toLowerCase()) + ) + }) +} + +/* lifted from the examples at https://react.dev/reference/react/Suspense */ +// @ts-ignore copied from untyped example +function use(promise) { + if (promise.status === 'fulfilled') { + return promise.value + } else if (promise.status === 'rejected') { + throw promise.reason + } else if (promise.status === 'pending') { + throw promise + } else { + promise.status = 'pending' + + // eslint-disable-next-line github/no-then + promise.then( + (result: Record) => { + promise.status = 'fulfilled' + promise.value = result + }, + (error: Error) => { + promise.status = 'rejected' + promise.reason = error + }, + ) + throw promise + } +} 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..fbb470671d3 --- /dev/null +++ b/src/drafts/SelectPanel2/stories/SelectPanel.playground.stories.tsx @@ -0,0 +1,124 @@ +import React from 'react' +import {SelectPanel} from '../SelectPanel' +import {ActionList, Box} from '../../../index' +import data from './mock-data' + +export default { + title: 'Drafts/Components/SelectPanel/Playground', + component: SelectPanel, +} + +export const Playground = () => { + 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 = () => { + // soft set, does not save until submit + setSelectedLabelIds([]) + } + + const onSubmit = () => { + data.issue.labelIds = selectedLabelIds // pretending to persist changes + + // eslint-disable-next-line no-console + console.log('form submitted') + } + + /* 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 ( + <> +

Controlled SelectPanel

+ + { + /* 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. */} + + + + + {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/mock-data.ts b/src/drafts/SelectPanel2/stories/mock-data.ts similarity index 100% rename from src/drafts/SelectPanel2/mock-data.ts rename to src/drafts/SelectPanel2/stories/mock-data.ts From 0b748ad0f9909bf5627e782bd9411cec6dea3290 Mon Sep 17 00:00:00 2001 From: Siddharth Kshetrapal Date: Tue, 21 Nov 2023 12:21:00 +0100 Subject: [PATCH 26/37] default story - done --- .../stories/SelectPanel.default.stories.tsx | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/src/drafts/SelectPanel2/stories/SelectPanel.default.stories.tsx b/src/drafts/SelectPanel2/stories/SelectPanel.default.stories.tsx index 2fbd66fd2ec..771bc350b50 100644 --- a/src/drafts/SelectPanel2/stories/SelectPanel.default.stories.tsx +++ b/src/drafts/SelectPanel2/stories/SelectPanel.default.stories.tsx @@ -17,17 +17,12 @@ export const Default = () => { if (!selectedLabelIds.includes(labelId)) setSelectedLabelIds([...selectedLabelIds, labelId]) 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 - - // eslint-disable-next-line no-console - console.log('form submitted') } /* Filtering */ @@ -40,7 +35,6 @@ export const Default = () => { if (query === '') setFilteredLabels(data.labels) else { - // Note: in the future, we should probably add a highlight for matching text setFilteredLabels( data.labels .map(label => { @@ -72,20 +66,16 @@ export const Default = () => { { /* 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. */} + From 2e30ea606854b90737f5af76363715a141f6a66c Mon Sep 17 00:00:00 2001 From: Siddharth Kshetrapal Date: Tue, 21 Nov 2023 12:27:08 +0100 Subject: [PATCH 27/37] split stories between examples and features --- .../stories/SelectPanel.examples.stories.tsx | 675 ++------------ .../stories/SelectPanel.features.stories.tsx | 853 ++---------------- 2 files changed, 169 insertions(+), 1359 deletions(-) diff --git a/src/drafts/SelectPanel2/stories/SelectPanel.examples.stories.tsx b/src/drafts/SelectPanel2/stories/SelectPanel.examples.stories.tsx index eac3f56507c..ee960b5ad52 100644 --- a/src/drafts/SelectPanel2/stories/SelectPanel.examples.stories.tsx +++ b/src/drafts/SelectPanel2/stories/SelectPanel.examples.stories.tsx @@ -1,15 +1,7 @@ import React from 'react' import {SelectPanel} from '../SelectPanel' -import {ActionList, ActionMenu, Avatar, Box, Button, Flash, Link, Text, ToggleSwitch} from '../../../index' -import { - ArrowRightIcon, - AlertIcon, - EyeIcon, - GitBranchIcon, - TriangleDownIcon, - TagIcon, - GearIcon, -} from '@primer/octicons-react' +import {ActionList, ActionMenu, Avatar, Box, Button, Flash} from '../../../index' +import {ArrowRightIcon, AlertIcon, EyeIcon, GitBranchIcon, TriangleDownIcon} from '@primer/octicons-react' import data from './mock-data' export default { @@ -21,7 +13,7 @@ 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) @@ -31,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 @@ -43,31 +30,6 @@ export const AControlled = () => { console.log('form submitted') } - /* 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 @@ -76,62 +38,35 @@ export const AControlled = () => { else return 1 } - const itemsToShow = query ? filteredLabels : data.labels.sort(sortingFn) + const itemsToShow = data.labels.sort(sortingFn) return ( <> -

Controlled SelectPanel

+

Minimal SelectPanel

- { - /* 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. */} - - - - {itemsToShow.length === 0 ? ( - - Try a different search term - - ) : ( - - {itemsToShow.map(label => ( - onLabelSelect(label.id)} - selected={selectedLabelIds.includes(label.id)} - > - {getCircle(label.color)} - {label.name} - {label.description} - - ))} - - )} - - - Edit labels - + + {itemsToShow.map(label => ( + onLabelSelect(label.id)} + selected={selectedLabelIds.includes(label.id)} + > + {getCircle(label.color)} + {label.name} + {label.description} + + ))} + + ) } -export const BWithSuspendedList = () => { +export const WithSuspendedList = () => { const [query, setQuery] = React.useState('') const onSearchInputChange: React.ChangeEventHandler = event => { @@ -215,7 +150,7 @@ const SuspendedActionList: React.FC<{query: string}> = ({query}) => { ) } -export const CAsyncSearchWithSuspenseKey = () => { +export const AsyncSearchWithSuspenseKey = () => { // issue `data` is already pre-fetched // `users` are fetched async on search @@ -321,7 +256,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('') @@ -370,254 +305,7 @@ export const DAsyncSearchWithUseTransition = () => { ) } -export const TODO2SingleSelection = () =>

TODO

- -export const HWithFilterButtons = () => { - const [selectedFilter, setSelectedFilter] = React.useState<'branches' | 'tags'>('branches') - - /* 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 [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), - ) - } - } - - React.useEffect( - function updateSearchResults() { - setSearchResults(query, selectedFilter) - }, - [query, selectedFilter], - ) - - const sortingFn = (ref: {id: string}) => { - if (ref.id === savedInitialRef) return -1 - else return 1 - } - - const itemsToShow = query ? filteredRefs : data[selectedFilter].sort(sortingFn) - - 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 = () => { +export const OpenFromMenu = () => { /* Open state */ const [menuOpen, setMenuOpen] = React.useState(false) const [selectPanelOpen, setSelectPanelOpen] = React.useState(false) @@ -735,295 +423,116 @@ export const GOpenFromMenu = () => { ) } -export const FInstantSelectionVariant = () => { - 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 WithFilterButtons = () => { + const [selectedFilter, setSelectedFilter] = React.useState<'branches' | 'tags'>('branches') -export const IWithWarning = () => { /* Selection */ + const [savedInitialRef, setSavedInitialRef] = React.useState(data.ref) + const [selectedRef, setSelectedRef] = React.useState(savedInitialRef) - 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 + setSavedInitialRef(selectedRef) + data.ref = selectedRef // pretending to persist changes + + // eslint-disable-next-line no-console + console.log('form submitted') } - /* Filtering */ - const [filteredUsers, setFilteredUsers] = React.useState(data.collaborators) + /* Filter */ const [query, setQuery] = React.useState('') - const onSearchInputChange: React.ChangeEventHandler = event => { const query = event.currentTarget.value setQuery(query) + } - if (query === '') setFilteredUsers(data.collaborators) + const [filteredRefs, setFilteredRefs] = React.useState(data.branches) + const setSearchResults = (query: string, selectedFilter: 'branches' | 'tags') => { + if (query === '') setFilteredRefs(data[selectedFilter]) 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} + 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.collaborator), + .map(result => result.item), ) } } - 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 + React.useEffect( + function updateSearchResults() { + setSearchResults(query, selectedFilter) + }, + [query, selectedFilter], + ) + + const sortingFn = (ref: {id: string}) => { + if (ref.id === savedInitialRef) return -1 else return 1 } - const itemsToShow = query ? filteredUsers : data.collaborators.sort(sortingFn) + const itemsToShow = query ? filteredRefs : data[selectedFilter].sort(sortingFn) return ( <> -

SelectPanel with warning

+

With Filter Buttons

- - - Assignees + + + {savedInitialRef} + - - {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 => ( + + {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} + + ) diff --git a/src/drafts/SelectPanel2/stories/SelectPanel.features.stories.tsx b/src/drafts/SelectPanel2/stories/SelectPanel.features.stories.tsx index f133bff01a0..54f46fd368c 100644 --- a/src/drafts/SelectPanel2/stories/SelectPanel.features.stories.tsx +++ b/src/drafts/SelectPanel2/stories/SelectPanel.features.stories.tsx @@ -1,15 +1,7 @@ import React from 'react' import {SelectPanel} from '../SelectPanel' -import {ActionList, ActionMenu, Avatar, Box, Button, Flash, Link, Text, ToggleSwitch} from '../../../index' -import { - ArrowRightIcon, - AlertIcon, - EyeIcon, - GitBranchIcon, - TriangleDownIcon, - TagIcon, - GearIcon, -} from '@primer/octicons-react' +import {ActionList, Avatar, Box, Button, Link, Text, ToggleSwitch} from '../../../index' +import {TagIcon, GearIcon} from '@primer/octicons-react' import data from './mock-data' export default { @@ -21,721 +13,7 @@ const getCircle = (color: string) => ( ) -export const AControlled = () => { - 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 = () => { - // soft set, does not save until submit - setSelectedLabelIds([]) - } - - const onSubmit = () => { - data.issue.labelIds = selectedLabelIds // pretending to persist changes - - // eslint-disable-next-line no-console - console.log('form submitted') - } - - /* 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 ( - <> -

Controlled SelectPanel

- - { - /* 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. */} - - - - - {itemsToShow.length === 0 ? ( - - Try a different search term - - ) : ( - - {itemsToShow.map(label => ( - onLabelSelect(label.id)} - selected={selectedLabelIds.includes(label.id)} - > - {getCircle(label.color)} - {label.name} - {label.description} - - ))} - - )} - - - Edit labels - - - - ) -} - -export const BWithSuspendedList = () => { - const [query, setQuery] = React.useState('') - - const onSearchInputChange: React.ChangeEventHandler = event => { - const query = event.currentTarget.value - setQuery(query) - } - - return ( - <> -

Suspended list

-

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

- - Assign label - - - - - - Fetching labels...}> - - - Edit labels - - - - - ) -} - -const SuspendedActionList: React.FC<{query: string}> = ({query}) => { - const fetchedData: typeof data = use(getData({key: 'suspended-action-list'})) - - /* Selection */ - const initialSelectedLabels: string[] = fetchedData.issue.labelIds - const [selectedLabelIds, setSelectedLabelIds] = React.useState(initialSelectedLabels) - - const onLabelSelect = (labelId: string) => { - if (!selectedLabelIds.includes(labelId)) setSelectedLabelIds([...selectedLabelIds, labelId]) - else setSelectedLabelIds(selectedLabelIds.filter(id => id !== labelId)) - } - - /* Filtering */ - const filteredLabels = fetchedData.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 itemsToShow.length === 0 ? ( - - Try a different search term - - ) : ( - - {itemsToShow.map(label => ( - onLabelSelect(label.id)} - selected={selectedLabelIds.includes(label.id)} - > - {getCircle(label.color)} - {label.name} - {label.description} - - ))} - - ) -} - -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 -*/ -const SearchableUserList: React.FC<{ - query: string - showLoading?: boolean - initialAssigneeIds: string[] - selectedUserIds: string[] - onUserSelect: (id: string) => void -}> = ({query, showLoading = false, initialAssigneeIds, selectedUserIds, onUserSelect}) => { - const repository = {collaborators: data.collaborators} - - /* Filtering */ - const filteredUsers: typeof data.users = query ? use(queryUsers({query})) : [] - - if (showLoading) return Search for users... - - const sortingFn = (itemA: {id: string}, itemB: {id: string}) => { - if (initialAssigneeIds.includes(itemA.id) && initialAssigneeIds.includes(itemB.id)) return 1 - else if (initialAssigneeIds.includes(itemA.id)) return -1 - else if (initialAssigneeIds.includes(itemB.id)) return 1 - else return 1 - } - const itemsToShow = query ? filteredUsers : repository.collaborators.sort(sortingFn) - - return itemsToShow.length === 0 ? ( - - Try a different search term - - ) : ( - - {itemsToShow.map(user => ( - onUserSelect(user.id)} - selected={selectedUserIds.includes(user.id)} - > - - - - {user.login} - {user.name} - - ))} - - ) -} - -/* - `data` is already pre-fetched with the issue - `users` are fetched async on search -*/ -export const DAsyncSearchWithUseTransition = () => { - const [isPending, startTransition] = React.useTransition() - - const [query, setQuery] = React.useState('') - const onSearchInputChange: React.ChangeEventHandler = event => { - const query = event.currentTarget.value - startTransition(() => 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 - - - - - Fetching users...}> - - - - - - ) -} - -export const TODO2SingleSelection = () =>

TODO

- -export const HWithFilterButtons = () => { - const [selectedFilter, setSelectedFilter] = React.useState<'branches' | 'tags'>('branches') - - /* 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 [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), - ) - } - } - - React.useEffect( - function updateSearchResults() { - setSearchResults(query, selectedFilter) - }, - [query, selectedFilter], - ) - - const sortingFn = (ref: {id: string}) => { - if (ref.id === savedInitialRef) return -1 - else return 1 - } - - const itemsToShow = query ? filteredRefs : data[selectedFilter].sort(sortingFn) - - 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 -

- - - setMenuOpen(value)}> - - - setSelectedSetting('Participating and @mentions')} - > - Participating and @mentions - - Only receive notifications from this repository when participating or @mentioned. - - - setSelectedSetting('All activity')} - > - All activity - - Notified of all notifications on this repository. - - - setSelectedSetting('Ignore')}> - Ignore - Never be notified. - - setSelectPanelOpen(true)}> - Custom - - - - - Select events you want to be notified of in addition to participating and @mentions. - - - - - - - { - setSelectPanelOpen(false) - onSelectPanelSubmit() - }} - onCancel={() => { - setSelectPanelOpen(false) - setMenuOpen(true) - }} - height="medium" - > - - {itemsToShow.map(item => ( - onEventSelect(item)} selected={selectedEvents.includes(item)}> - {item} - - ))} - - - - - ) -} - -export const FInstantSelectionVariant = () => { +export const InstantSelectionVariant = () => { const [selectedTag, setSelectedTag] = React.useState() const onSubmit = () => { @@ -767,7 +45,9 @@ export const FInstantSelectionVariant = () => { ) } -export const IWithWarning = () => { +export const SingleSelection = () =>

TODO

+ +export const WithWarning = () => { /* Selection */ const initialAssigneeIds = data.issue.assigneeIds // mock initial state @@ -877,7 +157,7 @@ export const IWithWarning = () => { ) } -export const JWithErrors = () => { +export const WithErrors = () => { const [searchBroken, setSearchBroken] = React.useState(true) const [issuesBroken, setIssuesBroken] = React.useState(false) @@ -1029,58 +309,79 @@ export const JWithErrors = () => { ) } -// ----- Suspense implementation details ---- - -const cache = new Map() -const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) +export const ExternalAnchor = () => { + const initialSelectedLabels = data.issue.labelIds // mock initial state: has selected labels + const [selectedLabelIds, setSelectedLabelIds] = React.useState(initialSelectedLabels) -const getData = ({key = '0', delay = 1000}: {key: string; delay?: number}) => { - if (!cache.has(key)) cache.set(key, fetchData(delay)) - return cache.get(key) -} -// return a promise! -const fetchData = async (delay: number) => { - await sleep(delay) - return data -} + /* Selection */ + const onLabelSelect = (labelId: string) => { + if (!selectedLabelIds.includes(labelId)) setSelectedLabelIds([...selectedLabelIds, labelId]) + else setSelectedLabelIds(selectedLabelIds.filter(id => id !== labelId)) + } -const queryUsers = ({query = '', delay = 500}: {query: string; delay?: number}) => { - const key = `users-${query}` - if (!cache.has(key)) cache.set(key, fetchUsers(query, delay)) - return cache.get(key) -} -const fetchUsers = async (query: string, delay: number) => { - await sleep(delay) - return data.users.filter(user => { - return ( - user.login.toLowerCase().includes(query.toLowerCase()) || user.name.toLowerCase().includes(query.toLowerCase()) - ) - }) -} + const onSubmit = () => { + data.issue.labelIds = selectedLabelIds // pretending to persist changes -/* lifted from the examples at https://react.dev/reference/react/Suspense */ -// @ts-ignore copied from untyped example -function use(promise) { - if (promise.status === 'fulfilled') { - return promise.value - } else if (promise.status === 'rejected') { - throw promise.reason - } else if (promise.status === 'pending') { - throw promise - } else { - promise.status = 'pending' + // eslint-disable-next-line no-console + console.log('form submitted') + } - // eslint-disable-next-line github/no-then - promise.then( - (result: Record) => { - promise.status = 'fulfilled' - promise.value = result - }, - (error: Error) => { - promise.status = 'rejected' - promise.reason = error - }, - ) - throw promise + 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} + + ))} + + + + + ) } From 6d0e281ae6298b14d521f8172b747c7be5303cf1 Mon Sep 17 00:00:00 2001 From: Siddharth Kshetrapal Date: Tue, 21 Nov 2023 12:30:04 +0100 Subject: [PATCH 28/37] remove default open from stories --- .../SelectPanel2/stories/SelectPanel.default.stories.tsx | 3 --- .../SelectPanel2/stories/SelectPanel.examples.stories.tsx | 8 ++++---- .../SelectPanel2/stories/SelectPanel.features.stories.tsx | 5 ++--- .../stories/SelectPanel.playground.stories.tsx | 3 --- 4 files changed, 6 insertions(+), 13 deletions(-) diff --git a/src/drafts/SelectPanel2/stories/SelectPanel.default.stories.tsx b/src/drafts/SelectPanel2/stories/SelectPanel.default.stories.tsx index 771bc350b50..923ffb5ad53 100644 --- a/src/drafts/SelectPanel2/stories/SelectPanel.default.stories.tsx +++ b/src/drafts/SelectPanel2/stories/SelectPanel.default.stories.tsx @@ -61,11 +61,8 @@ export const Default = () => { return ( <> -

Controlled SelectPanel

- { /* optional callback, for example: for multi-step overlay or to fire sync actions */ diff --git a/src/drafts/SelectPanel2/stories/SelectPanel.examples.stories.tsx b/src/drafts/SelectPanel2/stories/SelectPanel.examples.stories.tsx index ee960b5ad52..741dbf97de4 100644 --- a/src/drafts/SelectPanel2/stories/SelectPanel.examples.stories.tsx +++ b/src/drafts/SelectPanel2/stories/SelectPanel.examples.stories.tsx @@ -44,7 +44,7 @@ export const Minimal = () => { <>

Minimal SelectPanel

- + Assign label @@ -180,7 +180,7 @@ export const AsyncSearchWithSuspenseKey = () => {

Async search with useTransition

Fetching items on every keystroke search (like github users)

- + Select assignees @@ -284,7 +284,7 @@ export const AsyncSearchWithUseTransition = () => {

Async search with useTransition

Fetching items on every keystroke search (like github users)

- + Select assignees @@ -480,7 +480,7 @@ export const WithFilterButtons = () => { <>

With Filter Buttons

- + {savedInitialRef} diff --git a/src/drafts/SelectPanel2/stories/SelectPanel.features.stories.tsx b/src/drafts/SelectPanel2/stories/SelectPanel.features.stories.tsx index 54f46fd368c..255edd3a9d2 100644 --- a/src/drafts/SelectPanel2/stories/SelectPanel.features.stories.tsx +++ b/src/drafts/SelectPanel2/stories/SelectPanel.features.stories.tsx @@ -27,7 +27,7 @@ export const InstantSelectionVariant = () => { <>

Instant selection variant

- + {selectedTag || 'Choose a tag'} @@ -106,7 +106,6 @@ export const WithWarning = () => { @@ -255,7 +254,7 @@ export const WithErrors = () => { />
- + { return ( <> -

Controlled SelectPanel

- Date: Tue, 21 Nov 2023 12:45:12 +0100 Subject: [PATCH 29/37] remove defaultOpen --- src/drafts/SelectPanel2/SelectPanel.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/drafts/SelectPanel2/SelectPanel.tsx b/src/drafts/SelectPanel2/SelectPanel.tsx index 1e093f1cc30..82380df27e8 100644 --- a/src/drafts/SelectPanel2/SelectPanel.tsx +++ b/src/drafts/SelectPanel2/SelectPanel.tsx @@ -48,7 +48,6 @@ export type SelectPanelProps = { selectionVariant?: ActionListProps['selectionVariant'] | 'instant' id?: string - defaultOpen?: boolean open?: boolean anchorRef?: React.RefObject @@ -69,7 +68,6 @@ const Panel: React.FC = ({ selectionVariant = 'multiple', id, - defaultOpen = false, open: propsOpen, anchorRef: providedAnchorRef, @@ -95,7 +93,7 @@ const Panel: React.FC = ({ return child }) - const [internalOpen, setInternalOpen] = React.useState(defaultOpen) + const [internalOpen, setInternalOpen] = React.useState(propsOpen) // sync open state React.useEffect(() => setInternalOpen(propsOpen || false), [propsOpen]) From 3010d8e25acb8b2c6b9f91ca0ef29a985ce44f8c Mon Sep 17 00:00:00 2001 From: Siddharth Kshetrapal Date: Tue, 21 Nov 2023 13:03:20 +0100 Subject: [PATCH 30/37] update playground --- .../SelectPanel.playground.stories.tsx | 55 +++++++++++-------- 1 file changed, 31 insertions(+), 24 deletions(-) diff --git a/src/drafts/SelectPanel2/stories/SelectPanel.playground.stories.tsx b/src/drafts/SelectPanel2/stories/SelectPanel.playground.stories.tsx index 76203ac0ca6..1d2d183e8e0 100644 --- a/src/drafts/SelectPanel2/stories/SelectPanel.playground.stories.tsx +++ b/src/drafts/SelectPanel2/stories/SelectPanel.playground.stories.tsx @@ -1,4 +1,5 @@ import React from 'react' +import {Meta, StoryFn} from '@storybook/react' import {SelectPanel} from '../SelectPanel' import {ActionList, Box} from '../../../index' import data from './mock-data' @@ -6,28 +7,37 @@ import data from './mock-data' export default { title: 'Drafts/Components/SelectPanel/Playground', component: SelectPanel, -} -export const Playground = () => { - const initialSelectedLabels = data.issue.labelIds // mock initial state: has selected labels + 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 (!selectedLabelIds.includes(labelId)) setSelectedLabelIds([...selectedLabelIds, labelId]) - else setSelectedLabelIds(selectedLabelIds.filter(id => id !== labelId)) + 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 = () => { - // soft set, does not save until submit - setSelectedLabelIds([]) - } + const onClearSelection = () => setSelectedLabelIds([]) const onSubmit = () => { data.issue.labelIds = selectedLabelIds // pretending to persist changes - - // eslint-disable-next-line no-console - console.log('form submitted') } /* Filtering */ @@ -68,21 +78,16 @@ export const Playground = () => { 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') - }} - // API TODO: onClearSelection feels even more odd on the parent, instead of on the header. onClearSelection={onClearSelection} + width={args.width} + height={args.height} > Assign label - {/* API TODO: header and heading is confusing. maybe skip header completely. */} + @@ -113,7 +118,9 @@ export const Playground = () => { )} - Edit labels + {args.secondaryButtonText ? ( + {args.secondaryButtonText} + ) : null} From 06fee77a110ed2f6abce211cb67e132096763ef5 Mon Sep 17 00:00:00 2001 From: Siddharth Kshetrapal Date: Tue, 21 Nov 2023 16:33:29 +0100 Subject: [PATCH 31/37] default value for open --- src/drafts/SelectPanel2/SelectPanel.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/drafts/SelectPanel2/SelectPanel.tsx b/src/drafts/SelectPanel2/SelectPanel.tsx index 82380df27e8..f42068eea55 100644 --- a/src/drafts/SelectPanel2/SelectPanel.tsx +++ b/src/drafts/SelectPanel2/SelectPanel.tsx @@ -67,8 +67,7 @@ const Panel: React.FC = ({ description, selectionVariant = 'multiple', id, - - open: propsOpen, + open: propsOpen = false, anchorRef: providedAnchorRef, onCancel: propsOnCancel, From fb97973c93558ab214cd1e75140c9155fa54e42a Mon Sep 17 00:00:00 2001 From: Siddharth Kshetrapal Date: Tue, 21 Nov 2023 16:38:10 +0100 Subject: [PATCH 32/37] tiny tweak! --- src/drafts/SelectPanel2/SelectPanel.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/drafts/SelectPanel2/SelectPanel.tsx b/src/drafts/SelectPanel2/SelectPanel.tsx index f42068eea55..4974afb7248 100644 --- a/src/drafts/SelectPanel2/SelectPanel.tsx +++ b/src/drafts/SelectPanel2/SelectPanel.tsx @@ -67,7 +67,7 @@ const Panel: React.FC = ({ description, selectionVariant = 'multiple', id, - open: propsOpen = false, + open: propsOpen, anchorRef: providedAnchorRef, onCancel: propsOnCancel, @@ -92,7 +92,7 @@ const Panel: React.FC = ({ return child }) - const [internalOpen, setInternalOpen] = React.useState(propsOpen) + const [internalOpen, setInternalOpen] = React.useState(propsOpen || false) // sync open state React.useEffect(() => setInternalOpen(propsOpen || false), [propsOpen]) From 59470acb416ea42185feff1a3c52464801b21c8e Mon Sep 17 00:00:00 2001 From: Siddharth Kshetrapal Date: Tue, 21 Nov 2023 16:55:19 +0100 Subject: [PATCH 33/37] delete suspense key stories, we don't recommend it --- .../stories/SelectPanel.examples.stories.tsx | 53 ------------------- 1 file changed, 53 deletions(-) diff --git a/src/drafts/SelectPanel2/stories/SelectPanel.examples.stories.tsx b/src/drafts/SelectPanel2/stories/SelectPanel.examples.stories.tsx index 741dbf97de4..d84ce41871f 100644 --- a/src/drafts/SelectPanel2/stories/SelectPanel.examples.stories.tsx +++ b/src/drafts/SelectPanel2/stories/SelectPanel.examples.stories.tsx @@ -150,59 +150,6 @@ const SuspendedActionList: React.FC<{query: string}> = ({query}) => { ) } -export const AsyncSearchWithSuspenseKey = () => { - // 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 From c5ac721d85e47dc392e4b3b8db9183866334a819 Mon Sep 17 00:00:00 2001 From: Siddharth Kshetrapal Date: Tue, 21 Nov 2023 16:59:25 +0100 Subject: [PATCH 34/37] use TextInputProps for SelectPanel.SearchInput --- src/drafts/SelectPanel2/SelectPanel.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/drafts/SelectPanel2/SelectPanel.tsx b/src/drafts/SelectPanel2/SelectPanel.tsx index 1e093f1cc30..3e87bde61cc 100644 --- a/src/drafts/SelectPanel2/SelectPanel.tsx +++ b/src/drafts/SelectPanel2/SelectPanel.tsx @@ -12,6 +12,7 @@ import { AnchoredOverlayProps, Tooltip, TextInput, + TextInputProps, Spinner, Text, ActionListProps, @@ -277,10 +278,7 @@ const SelectPanelHeader: React.FC = ({children, ...prop ) } -type SearchInputProps = { - onChange: React.ChangeEventHandler -} -const SelectPanelSearchInput: React.FC = ({onChange: propsOnChange, ...props}) => { +const SelectPanelSearchInput: React.FC = ({onChange: propsOnChange, ...props}) => { const inputRef = React.createRef() const {setSearchQuery} = React.useContext(SelectPanelContext) From 578756ad85cb1c1307ebd5af759577d9397cb2d0 Mon Sep 17 00:00:00 2001 From: Siddharth Kshetrapal Date: Tue, 21 Nov 2023 17:03:20 +0100 Subject: [PATCH 35/37] use loading on SearchInput for transition story --- .../stories/SelectPanel.examples.stories.tsx | 25 ++++++++----------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/src/drafts/SelectPanel2/stories/SelectPanel.examples.stories.tsx b/src/drafts/SelectPanel2/stories/SelectPanel.examples.stories.tsx index d84ce41871f..4b0dc9164a6 100644 --- a/src/drafts/SelectPanel2/stories/SelectPanel.examples.stories.tsx +++ b/src/drafts/SelectPanel2/stories/SelectPanel.examples.stories.tsx @@ -66,7 +66,7 @@ export const Minimal = () => { ) } -export const WithSuspendedList = () => { +export const AsyncWithSuspendedList = () => { const [query, setQuery] = React.useState('') const onSearchInputChange: React.ChangeEventHandler = event => { @@ -76,7 +76,7 @@ export const WithSuspendedList = () => { return ( <> -

Suspended list

+

Async: Suspended list

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

Assign label @@ -228,25 +228,22 @@ export const AsyncSearchWithUseTransition = () => { return ( <> -

Async search with useTransition

+

Async: search with useTransition

Fetching items on every keystroke search (like github users)

Select assignees - + - Fetching users...}> - - - + + ) From 5259bd921de64210854a5fb51cf5b90262bd4be7 Mon Sep 17 00:00:00 2001 From: Siddharth Kshetrapal Date: Tue, 28 Nov 2023 15:16:52 +0100 Subject: [PATCH 36/37] add example for groups --- .../stories/SelectPanel.examples.stories.tsx | 121 +++++++++++++++++- src/drafts/SelectPanel2/stories/mock-data.ts | 3 + 2 files changed, 122 insertions(+), 2 deletions(-) diff --git a/src/drafts/SelectPanel2/stories/SelectPanel.examples.stories.tsx b/src/drafts/SelectPanel2/stories/SelectPanel.examples.stories.tsx index 4b0dc9164a6..bab22d323a9 100644 --- a/src/drafts/SelectPanel2/stories/SelectPanel.examples.stories.tsx +++ b/src/drafts/SelectPanel2/stories/SelectPanel.examples.stories.tsx @@ -1,7 +1,7 @@ import React from 'react' import {SelectPanel} from '../SelectPanel' -import {ActionList, ActionMenu, Avatar, Box, Button, Flash} from '../../../index' -import {ArrowRightIcon, AlertIcon, EyeIcon, GitBranchIcon, TriangleDownIcon} from '@primer/octicons-react' +import {ActionList, ActionMenu, Avatar, Box, Button, Flash, Link} from '../../../index' +import {ArrowRightIcon, AlertIcon, EyeIcon, GitBranchIcon, TriangleDownIcon, GearIcon} from '@primer/octicons-react' import data from './mock-data' export default { @@ -66,6 +66,123 @@ export const Minimal = () => { ) } +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 [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 groups

+ + + + Reviewers + + + + + + {itemsToShow.length === 0 ? ( + + Try a different search term + + ) : ( + + + 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} + + ))} + + + )} + + + + + ) +} + export const AsyncWithSuspendedList = () => { const [query, setQuery] = React.useState('') diff --git a/src/drafts/SelectPanel2/stories/mock-data.ts b/src/drafts/SelectPanel2/stories/mock-data.ts index 309be3bd23c..75f86fd3c77 100644 --- a/src/drafts/SelectPanel2/stories/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', From 859e5ae63d548fd8da99fabf5e0723e9722e3d89 Mon Sep 17 00:00:00 2001 From: Siddharth Kshetrapal Date: Tue, 5 Dec 2023 15:55:15 +0100 Subject: [PATCH 37/37] call storybook actions --- .../stories/SelectPanel.playground.stories.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/drafts/SelectPanel2/stories/SelectPanel.playground.stories.tsx b/src/drafts/SelectPanel2/stories/SelectPanel.playground.stories.tsx index 1d2d183e8e0..a9d318eccc7 100644 --- a/src/drafts/SelectPanel2/stories/SelectPanel.playground.stories.tsx +++ b/src/drafts/SelectPanel2/stories/SelectPanel.playground.stories.tsx @@ -1,6 +1,6 @@ import React from 'react' import {Meta, StoryFn} from '@storybook/react' -import {SelectPanel} from '../SelectPanel' +import {SelectPanel, SelectPanelProps} from '../SelectPanel' import {ActionList, Box} from '../../../index' import data from './mock-data' @@ -34,12 +34,18 @@ export const Playground: StoryFn = args => { } } - const onClearSelection = () => setSelectedLabelIds([]) + const onClearSelection = () => { + setSelectedLabelIds([]) + args.onClearSelection() // call storybook action + } - const onSubmit = () => { + 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('') @@ -82,6 +88,7 @@ export const Playground: StoryFn = args => { description={args.description} selectionVariant={args.selectionVariant} onSubmit={onSubmit} + onCancel={onCancel} onClearSelection={onClearSelection} width={args.width} height={args.height}