1- import {
2- Box ,
3- Checkbox ,
4- CircularProgress ,
5- FormControlLabel ,
6- styled ,
7- Typography ,
8- } from '@mui/material' ;
9- import { T } from '@tolgee/react' ;
1+ import { useState } from 'react' ;
2+ import { styled , Typography } from '@mui/material' ;
3+ import { T , useTranslate } from '@tolgee/react' ;
4+ import { useDebounce } from 'use-debounce' ;
5+
6+ import { useApiInfiniteQuery } from 'tg.service/http/useQueryApi' ;
7+ import { InfiniteMultiSearchSelect } from 'tg.component/searchSelect/InfiniteMultiSearchSelect' ;
8+ import { MultiselectItem } from 'tg.component/searchSelect/MultiselectItem' ;
9+ import { components } from 'tg.service/apiSchema.generated' ;
10+ import { TranslatedError } from 'tg.translationTools/TranslatedError' ;
11+
12+ type ProjectModel = components [ 'schemas' ] [ 'ProjectModel' ] ;
1013
1114export type SelectableProject = {
1215 id : number ;
1316 name : string ;
1417} ;
1518
16- const StyledList = styled ( 'div' ) `
17- display: grid;
18- max-height: 240px;
19- overflow-y: auto;
20- border-radius: ${ ( { theme } ) => theme . shape . borderRadius } px;
21- border: 1px solid ${ ( { theme } ) => theme . palette . divider } ;
22- padding: ${ ( { theme } ) => theme . spacing ( 0.5 , 1 ) } ;
23- ` ;
19+ const SEARCH_DEBOUNCE_MS = 500 ;
20+ const PAGE_SIZE = 30 ;
2421
2522const StyledEmpty = styled ( 'div' ) `
2623 padding: ${ ( { theme } ) => theme . spacing ( 2 ) } ;
@@ -30,31 +27,82 @@ const StyledEmpty = styled('div')`
3027` ;
3128
3229type Props = {
33- projects : SelectableProject [ ] ;
34- loading : boolean ;
35- truncated : boolean ;
36- selectedIds : number [ ] ;
30+ organizationId : number ;
31+ selected : SelectableProject [ ] ;
3732 disabled ?: boolean ;
38- onChange : ( ids : number [ ] ) => void ;
33+ onChange : ( projects : SelectableProject [ ] ) => void ;
3934} ;
4035
4136export const AppOrganizationProjectsSelect = ( {
42- projects,
43- loading,
44- truncated,
45- selectedIds,
37+ organizationId,
38+ selected,
4639 disabled,
4740 onChange,
4841} : Props ) => {
49- if ( loading ) {
50- return (
51- < Box display = "flex" justifyContent = "center" py = { 2 } >
52- < CircularProgress size = { 20 } />
53- </ Box >
54- ) ;
55- }
42+ const { t } = useTranslate ( ) ;
43+ const [ search , setSearch ] = useState ( '' ) ;
44+ const [ searchDebounced ] = useDebounce ( search , SEARCH_DEBOUNCE_MS ) ;
45+
46+ const query = {
47+ search : searchDebounced ,
48+ size : PAGE_SIZE ,
49+ } ;
50+
51+ const projectsLoadable = useApiInfiniteQuery ( {
52+ url : '/v2/organizations/{id}/projects' ,
53+ method : 'get' ,
54+ path : { id : organizationId } ,
55+ query,
56+ options : {
57+ keepPreviousData : true ,
58+ noGlobalLoading : true ,
59+ // Reported inline on the select instead of as a global toast.
60+ onError : ( ) => undefined ,
61+ getNextPageParam : ( lastPage ) => {
62+ if (
63+ lastPage . page &&
64+ lastPage . page . number ! < lastPage . page . totalPages ! - 1
65+ ) {
66+ return {
67+ path : { id : organizationId } ,
68+ query : { ...query , page : lastPage . page ! . number ! + 1 } ,
69+ } ;
70+ }
71+ return null ;
72+ } ,
73+ } ,
74+ } ) ;
75+
76+ const items = projectsLoadable . data ?. pages . flatMap (
77+ ( page ) => page . _embedded ?. projects ?? [ ]
78+ ) ;
79+
80+ const totalElements = projectsLoadable . data ?. pages [ 0 ] ?. page ?. totalElements ;
81+ const organizationHasNoProjects = ! searchDebounced && totalElements === 0 ;
82+
83+ const handleFetchMore = ( ) => {
84+ if ( projectsLoadable . hasNextPage && ! projectsLoadable . isFetching ) {
85+ projectsLoadable . fetchNextPage ( ) ;
86+ }
87+ } ;
88+
89+ const toggleSelected = ( project : ProjectModel ) => {
90+ if ( selected . some ( ( item ) => item . id === project . id ) ) {
91+ onChange ( selected . filter ( ( item ) => item . id !== project . id ) ) ;
92+ return ;
93+ }
94+ onChange ( [ ...selected , { id : project . id , name : project . name } ] ) ;
95+ } ;
96+
97+ const renderError = ( ) => {
98+ if ( ! projectsLoadable . error ) return undefined ;
99+ if ( typeof projectsLoadable . error . code === 'string' ) {
100+ return < TranslatedError code = { projectsLoadable . error . code } /> ;
101+ }
102+ return < T keyName = "simple_paginated_list_error_message" /> ;
103+ } ;
56104
57- if ( projects . length === 0 ) {
105+ if ( organizationHasNoProjects ) {
58106 return (
59107 < StyledEmpty data-cy = "administration-apps-projects-empty" >
60108 < Typography variant = "body2" >
@@ -67,78 +115,34 @@ export const AppOrganizationProjectsSelect = ({
67115 ) ;
68116 }
69117
70- const selected = new Set ( selectedIds ) ;
71- const allSelected = projects . every ( ( project ) => selected . has ( project . id ) ) ;
72-
73- const toggle = ( projectId : number ) => {
74- if ( selected . has ( projectId ) ) {
75- onChange ( selectedIds . filter ( ( id ) => id !== projectId ) ) ;
76- return ;
77- }
78- onChange ( [ ...selectedIds , projectId ] ) ;
79- } ;
80-
81- const toggleAll = ( ) => {
82- if ( allSelected ) {
83- onChange ( [ ] ) ;
84- return ;
85- }
86- onChange ( projects . map ( ( project ) => project . id ) ) ;
87- } ;
88-
89118 return (
90- < >
91- < StyledList data-cy = "administration-apps-projects-select" >
92- < FormControlLabel
93- control = {
94- < Checkbox
95- size = "small"
96- checked = { allSelected }
97- indeterminate = { ! allSelected && selectedIds . length > 0 }
98- disabled = { disabled }
99- onChange = { toggleAll }
100- data-cy = "administration-apps-projects-select-all"
101- />
102- }
103- label = {
104- < Typography variant = "body2" color = "text.secondary" >
105- < T
106- keyName = "administration_apps_projects_select_all"
107- defaultValue = "Select all"
108- />
109- </ Typography >
110- }
119+ < InfiniteMultiSearchSelect
120+ items = { items }
121+ selected = { selected }
122+ queryResult = { projectsLoadable }
123+ itemKey = { ( project ) => project . id }
124+ search = { search }
125+ onSearchChange = { setSearch }
126+ onFetchMore = { handleFetchMore }
127+ onClearSelected = { ( ) => onChange ( [ ] ) }
128+ renderItem = { ( props , project ) => (
129+ < MultiselectItem
130+ { ...props }
131+ data-cy = "administration-apps-projects-item"
132+ data-cy-project-id = { project . id }
133+ selected = { selected . some ( ( item ) => item . id === project . id ) }
134+ label = { project . name }
135+ onClick = { ( ) => toggleSelected ( project ) }
111136 />
112- { projects . map ( ( project ) => (
113- < FormControlLabel
114- key = { project . id }
115- control = {
116- < Checkbox
117- size = "small"
118- checked = { selected . has ( project . id ) }
119- disabled = { disabled }
120- onChange = { ( ) => toggle ( project . id ) }
121- data-cy = "administration-apps-projects-item"
122- data-cy-project-id = { project . id }
123- />
124- }
125- label = { < Typography variant = "body2" > { project . name } </ Typography > }
126- />
127- ) ) }
128- </ StyledList >
129- { truncated && (
130- < Typography
131- variant = "caption"
132- color = "text.secondary"
133- data-cy = "administration-apps-projects-truncated"
134- >
135- < T
136- keyName = "administration_apps_projects_truncated"
137- defaultValue = "Only the first {count} projects are listed. Enable the app for the rest from their project settings."
138- params = { { count : projects . length } }
139- />
140- </ Typography >
141137 ) }
142- </ >
138+ labelItem = { ( project ) => project . name }
139+ label = { t ( 'administration_apps_projects_select_label' , 'Projects' ) }
140+ searchPlaceholder = { t (
141+ 'administration_apps_projects_search_placeholder' ,
142+ 'Search projects…'
143+ ) }
144+ error = { renderError ( ) }
145+ disabled = { disabled }
146+ />
143147 ) ;
144148} ;
0 commit comments