11import {
22 useState , useEffect , useRef , useMemo ,
33} from 'react' ;
4+ import { useIntl } from '@edx/frontend-platform/i18n' ;
45import {
56 Form , Spinner , Dropdown , Icon , Badge ,
7+ Stack ,
68} from '@openedx/paragon' ;
79import {
810 Search , FilterList , ExpandLess , ExpandMore ,
911} from '@openedx/paragon/icons' ;
10- import { useScopes , useOrganizations } from '../data/hooks' ;
12+ import { useScopes , useOrganizations , useManagedScopeOrgs } from '../data/hooks' ;
1113import { courseRolesMetadata , libraryRolesMetadata } from '../constants' ;
1214import { ScopeItem } from '../data/api' ;
15+ import messages from './messages' ;
1316
1417const allRolesMetadata = [ ...courseRolesMetadata , ...libraryRolesMetadata ] ;
1518
@@ -51,15 +54,15 @@ const ScopeCheckboxItem = ({ scope, checked, onToggle }: ScopeCheckboxItemProps)
5154) ;
5255
5356interface OrgSectionProps {
54- org : string ;
5557 orgName : string ;
5658 scopes : ScopeItem [ ] ;
5759 selectedScopes : Set < string > ;
5860 onScopeToggle : ( scopeId : string ) => void ;
61+ aggregateScopeItem ?: ScopeItem ;
5962}
6063
6164const OrgSection = ( {
62- orgName, scopes, selectedScopes, onScopeToggle,
65+ orgName, scopes, selectedScopes, onScopeToggle, aggregateScopeItem ,
6366} : OrgSectionProps ) => {
6467 const [ collapsed , setCollapsed ] = useState ( false ) ;
6568
@@ -77,6 +80,13 @@ const OrgSection = ({
7780
7881 { ! collapsed && (
7982 < div className = "pl-2" >
83+ { aggregateScopeItem && (
84+ < ScopeCheckboxItem
85+ scope = { aggregateScopeItem }
86+ checked = { selectedScopes . has ( aggregateScopeItem . id ) }
87+ onToggle = { onScopeToggle }
88+ />
89+ ) }
8090 { scopes . map ( ( scope ) => (
8191 < ScopeCheckboxItem
8292 key = { scope . id }
@@ -102,6 +112,7 @@ const DefineApplicationScopeStep = ({
102112 selectedScopes,
103113 onScopeToggle,
104114} : DefineApplicationScopeStepProps ) => {
115+ const intl = useIntl ( ) ;
105116 const [ search , setSearch ] = useState ( '' ) ;
106117 const [ debouncedSearch , setDebouncedSearch ] = useState ( '' ) ;
107118 const [ selectedOrg , setSelectedOrg ] = useState < string > ( '' ) ;
@@ -121,13 +132,21 @@ const DefineApplicationScopeStep = ({
121132 hasNextPage,
122133 isFetchingNextPage,
123134 isLoading,
135+ isError,
124136 } = useScopes ( {
125137 contextType,
126138 search : debouncedSearch || undefined ,
127139 org : selectedOrg || undefined ,
128140 } ) ;
129141
130142 const { data : organizations } = useOrganizations ( contextType ) ;
143+ const { data : managedOrgs } = useManagedScopeOrgs ( contextType ) ;
144+
145+ const allowedOrgAggregates = managedOrgs ?? new Set < string > ( ) ;
146+
147+ const hasPlatformPermission = ! ! organizations ?. length
148+ && ! ! managedOrgs
149+ && organizations . every ( ( o ) => managedOrgs . has ( o . org ) ) ;
131150
132151 const allScopes = useMemo (
133152 ( ) => scopesData ?. pages . flatMap ( ( page ) => page . results ) ?? [ ] ,
@@ -143,11 +162,20 @@ const DefineApplicationScopeStep = ({
143162
144163 const scopesByOrg = useMemo ( ( ) => {
145164 const orgScopes = allScopes . filter ( ( s ) => ! ! s . org ) ;
146- return orgScopes . reduce < Record < string , ScopeItem [ ] > > ( ( acc , scope ) => {
165+ const grouped = orgScopes . reduce < Record < string , ScopeItem [ ] > > ( ( acc , scope ) => {
147166 if ( ! acc [ scope . org ] ) { acc [ scope . org ] = [ ] ; }
148167 acc [ scope . org ] . push ( scope ) ;
149168 return acc ;
150169 } , { } ) ;
170+
171+ Object . keys ( grouped ) . forEach ( ( org ) => {
172+ grouped [ org ] . sort ( ( a , b ) => {
173+ const aIsAll = a . name . startsWith ( 'All ' ) ? 0 : 1 ;
174+ const bIsAll = b . name . startsWith ( 'All ' ) ? 0 : 1 ;
175+ return aIsAll - bIsAll ;
176+ } ) ;
177+ } ) ;
178+ return grouped ;
151179 } , [ allScopes ] ) ;
152180
153181 const orderedOrgs = useMemo ( ( ) => Object . keys ( scopesByOrg ) , [ scopesByOrg ] ) ;
@@ -157,23 +185,45 @@ const DefineApplicationScopeStep = ({
157185 if ( ! el ) { return undefined ; }
158186 const observer = new IntersectionObserver (
159187 ( entries ) => {
160- if ( entries [ 0 ] . isIntersecting && hasNextPage && ! isFetchingNextPage ) {
188+ if ( entries [ 0 ] . isIntersecting && hasNextPage && ! isFetchingNextPage && ! isError ) {
161189 fetchNextPage ( ) ;
162190 }
163191 } ,
164192 { threshold : 0.1 } ,
165193 ) ;
166194 observer . observe ( el ) ;
167195 return ( ) => observer . disconnect ( ) ;
168- } , [ hasNextPage , isFetchingNextPage , fetchNextPage ] ) ;
196+ } , [ hasNextPage , isFetchingNextPage , isError , fetchNextPage ] ) ;
169197
170198 const selectedOrgLabel = organizations ?. find ( ( o ) => o . org === selectedOrg ) ?. name
171199 || organizations ?. find ( ( o ) => o . org === selectedOrg ) ?. org
172200 || 'Organization' ;
173201
202+ const aggregateLabel = contextType === 'course'
203+ ? 'All courses in this organization'
204+ : 'All libraries in this organization' ;
205+
206+ const aggregateDescription = contextType === 'course'
207+ ? 'Includes current and future courses'
208+ : 'Includes current and future libraries' ;
209+
210+ const platformAggregateLabel = contextType === 'course'
211+ ? 'All courses in Platform'
212+ : 'All libraries in Platform' ;
213+
214+ const platformAggregateScopeItem : ScopeItem | null = ( hasPlatformPermission && contextType )
215+ ? {
216+ id : '*' ,
217+ name : platformAggregateLabel ,
218+ description : aggregateDescription ,
219+ org : '' ,
220+ contextType : contextType as 'course' | 'library' ,
221+ }
222+ : null ;
223+
174224 return (
175225 < div className = "define-application-scope-step" >
176- < h3 className = "mb-4" > Applies to </ h3 >
226+ < h3 className = "mb-4" > { intl . formatMessage ( messages [ 'wizard.step.defineScope.title' ] ) } </ h3 >
177227
178228 { /* Search + Organization filter + count */ }
179229 < div className = "d-flex align-items-center justify-content-between gap-3 mb-2 flex-wrap" >
@@ -219,14 +269,16 @@ const DefineApplicationScopeStep = ({
219269
220270 { /* Active filter chip */ }
221271 { contextType && (
222- < div className = "mb-3 d-flex align-items-center gap-2 ">
272+ < Stack direction = "horizontal" gap = { 2 } className = " align-items-center">
223273 < span className = "text-muted small" > Filter applied:</ span >
224- < Badge className = "py-1 px-2" style = { { background : '#e8e8e8' , color : '#333' , fontWeight : 'normal' } } >
274+ < Badge className = "py-1 px-2" variant = "light" >
225275 { contextLabel }
226276 </ Badge >
227- </ div >
277+ </ Stack >
228278 ) }
229279
280+ < hr className = "my-4" />
281+
230282 { /* Scopes list */ }
231283 < div
232284 className = "scope-list border rounded p-3"
@@ -238,6 +290,15 @@ const DefineApplicationScopeStep = ({
238290 </ div >
239291 ) : (
240292 < >
293+ { /* Platform-wide aggregate option (permission-gated) */ }
294+ { platformAggregateScopeItem && (
295+ < ScopeCheckboxItem
296+ scope = { platformAggregateScopeItem }
297+ checked = { selectedScopes . has ( platformAggregateScopeItem . id ) }
298+ onToggle = { onScopeToggle }
299+ />
300+ ) }
301+
241302 { platformScopes . map ( ( scope ) => (
242303 < ScopeCheckboxItem
243304 key = { scope . id }
@@ -247,16 +308,28 @@ const DefineApplicationScopeStep = ({
247308 />
248309 ) ) }
249310
250- { orderedOrgs . map ( ( org ) => (
251- < OrgSection
252- key = { org }
253- org = { org }
254- orgName = { organizations ?. find ( ( o ) => o . org === org ) ?. name || org }
255- scopes = { scopesByOrg [ org ] }
256- selectedScopes = { selectedScopes }
257- onScopeToggle = { onScopeToggle }
258- />
259- ) ) }
311+ { orderedOrgs . map ( ( org ) => {
312+ const aggregateScopeItem : ScopeItem | undefined = ( allowedOrgAggregates . has ( org ) && contextType )
313+ ? {
314+ id : `org:${ org } ` ,
315+ name : aggregateLabel ,
316+ description : aggregateDescription ,
317+ org,
318+ contextType : contextType as 'course' | 'library' ,
319+ }
320+ : undefined ;
321+
322+ return (
323+ < OrgSection
324+ key = { org }
325+ orgName = { organizations ?. find ( ( o ) => o . org === org ) ?. name || org }
326+ scopes = { scopesByOrg [ org ] }
327+ selectedScopes = { selectedScopes }
328+ onScopeToggle = { onScopeToggle }
329+ aggregateScopeItem = { aggregateScopeItem }
330+ />
331+ ) ;
332+ } ) }
260333
261334 { allScopes . length === 0 && (
262335 < p className = "text-muted text-center py-3" > No scopes found.</ p >
0 commit comments