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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions frontend/common/hooks/useFeatureListWithApiKey.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { useMemo } from 'react'
import { skipToken } from '@reduxjs/toolkit/query'
import { useGetFeatureListQuery } from 'common/services/useProjectFlag'
import { useProjectEnvironments } from './useProjectEnvironments'
import { buildApiFilterParams } from 'common/utils/featureFilterParams'
import type { FilterState } from 'common/types/featureFilters'

/**
* Fetches filtered feature list, accepting environment API key instead of numeric ID.
*
* TODO: This wrapper will be removed once we standardize environmentId and environmentApiKey on RouteContext.
*/
export function useFeatureListWithApiKey(
filters: FilterState,
page: number,
environmentApiKey: string | undefined,
projectId: number | undefined,
): ReturnType<typeof useGetFeatureListQuery> {
const { getEnvironmentIdFromKey, isLoading: isLoadingEnvironments } =
useProjectEnvironments(projectId!)

const apiParams = useMemo(() => {
if (!environmentApiKey || !projectId || !getEnvironmentIdFromKey) {
return null
}
return buildApiFilterParams(
filters,
page,
environmentApiKey,
projectId,
getEnvironmentIdFromKey,
)
}, [filters, page, environmentApiKey, projectId, getEnvironmentIdFromKey])

return useGetFeatureListQuery(apiParams ?? skipToken, {
refetchOnMountOrArgChange: true,
skip: isLoadingEnvironments,
})
}
84 changes: 84 additions & 0 deletions frontend/common/hooks/usePageTracking.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { useEffect } from 'react'

/**
* Options for configuring page tracking behavior.
*/
export type PageTrackingOptions = {
/** The page constant name from Constants.pages */
pageName: string
/** Context data for tracking and storage persistence */
context?: {
environmentId?: string
projectId?: number
organisationId?: number
}
/** Whether to save context to AsyncStorage (default: false) */
saveToStorage?: boolean
/** Custom dependencies for re-tracking on changes */
deps?: React.DependencyList
}

/**
* Unified hook for tracking page views with optional context persistence.
*
* Consolidates both page tracking and environment context storage into a single,
* flexible hook. Automatically calls API.trackPage and optionally persists
* environment context to AsyncStorage.
*
* @param options - Configuration object for page tracking
*
* @example
* ```tsx
* // Basic page tracking only
* usePageTracking({ pageName: Constants.pages.FEATURES })
*
* // With context and storage persistence
* usePageTracking({
* pageName: Constants.pages.FEATURES,
* context: { environmentId, projectId, organisationId },
* saveToStorage: true,
* })
*
* // With custom dependencies
* usePageTracking({
* pageName: Constants.pages.FEATURES,
* context: { projectId },
* deps: [projectId, someOtherDep],
* })
* ```
*/
export function usePageTracking(options: PageTrackingOptions): void {
const { context, deps = [], pageName, saveToStorage = false } = options

// Track page view
useEffect(() => {
if (typeof API !== 'undefined' && API.trackPage) {
API.trackPage(pageName)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps)

// Persist environment context to storage if enabled
useEffect(() => {
if (saveToStorage && context) {
if (typeof AsyncStorage !== 'undefined' && AsyncStorage.setItem) {
AsyncStorage.setItem(
'lastEnv',
JSON.stringify({
environmentId: context.environmentId,
orgId: context.organisationId,
projectId: context.projectId,
}),
)
}
}
// We intentionally use individual properties instead of context object
// to prevent re-runs when object reference changes but values don't
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
saveToStorage,
context?.environmentId,
context?.organisationId,
context?.projectId,
])
}
58 changes: 58 additions & 0 deletions frontend/common/hooks/useProjectEnvironments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { useCallback, useMemo } from 'react'
import { useGetEnvironmentsQuery } from 'common/services/useEnvironment'
import { useGetProjectQuery } from 'common/services/useProject'
import type { Environment, Project } from 'common/types/responses'

interface UseProjectEnvironmentsResult {
project: Project | undefined
environments: Environment[]
getEnvironmentIdFromKey: (apiKey: string) => number | undefined
getEnvironment: (apiKey: string) => Environment | undefined
isLoading: boolean
error: Error | undefined
}

/** Fetches project and environment data with accessor functions for API key lookups. */
export function useProjectEnvironments(
projectId: number,
): UseProjectEnvironmentsResult {
const {
data: project,
error: projectError,
isLoading: isLoadingProject,
} = useGetProjectQuery({ id: projectId }, { skip: !projectId })

const {
data: environmentsData,
error: environmentsError,
isLoading: isLoadingEnvironments,
} = useGetEnvironmentsQuery({ projectId }, { skip: !projectId })

const environments = useMemo(
() => environmentsData?.results ?? [],
[environmentsData?.results],
)

const getEnvironmentIdFromKey = useCallback(
(apiKey: string): number | undefined => {
return environments.find((env) => env.api_key === apiKey)?.id
},
[environments],
)

const getEnvironment = useCallback(
(apiKey: string): Environment | undefined => {
return environments.find((env) => env.api_key === apiKey)
},
[environments],
)

return {
environments,
error: projectError || environmentsError,
getEnvironment,
getEnvironmentIdFromKey,
isLoading: isLoadingProject || isLoadingEnvironments,
project,
}
}
138 changes: 128 additions & 10 deletions frontend/common/providers/Permission.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,51 @@ import { PermissionLevel } from 'common/types/requests'
import AccountStore from 'common/stores/account-store'
import intersection from 'lodash/intersection'
import { cloneDeep } from 'lodash'
import Utils from 'common/utils/utils'
import Constants from 'common/constants'

/**
* Props for the Permission component
*
* @property {number | string} id - The ID of the resource (projectId, organisationId, environmentId, etc.)
* @property {string} permission - The permission key to check (e.g., 'CREATE_FEATURE', 'UPDATE_FEATURE')
* @property {PermissionLevel} level - The permission level ('project', 'organisation', 'environment')
* @property {number[]} [tags] - Optional tag IDs for tag-based permission checking
* @property {ReactNode | ((data: { permission: boolean; isLoading: boolean }) => ReactNode)} children - Content to render or render function
* @property {ReactNode} [fallback] - Optional content to render when permission is denied
* @property {string} [permissionName] - Optional custom permission name for tooltip display
* @property {boolean} [showTooltip=false] - Whether to show a tooltip when permission is denied
*/
type PermissionType = {
id: any
id: number | string
permission: string
tags?: number[]
level: PermissionLevel
children: (data: { permission: boolean; isLoading: boolean }) => ReactNode
children:
| ReactNode
| ((data: { permission: boolean; isLoading: boolean }) => ReactNode)
fallback?: ReactNode
permissionName?: string
showTooltip?: boolean
}

/**
* Hook to check if the current user has a specific permission
*
* Fetches permission data and checks if the user has the requested permission.
* Supports tag-based permissions where additional permissions can be granted
* based on tag intersection.
*
* @param {Object} params - The permission check parameters
* @param {number | string} params.id - The resource ID to check permissions for
* @param {PermissionLevel} params.level - The permission level to check at
* @param {string} params.permission - The permission key to check
* @param {number[]} [params.tags] - Optional tag IDs for tag-based permission checking
* @returns {Object} Object containing permission status and loading state
* @returns {boolean} returns.isLoading - Whether the permission data is still loading
* @returns {boolean} returns.isSuccess - Whether the permission data was fetched successfully
* @returns {boolean} returns.permission - Whether the user has the requested permission
*/
export const useHasPermission = ({
id,
level,
Expand Down Expand Up @@ -45,11 +81,68 @@ export const useHasPermission = ({
}
}

/**
* Permission component for conditional rendering based on user permissions
*
* This component checks if the current user has a specific permission and conditionally
* renders its children. It supports multiple rendering patterns:
*
* @example
* // Basic usage with simple children
* <Permission level="project" permission="CREATE_FEATURE" id={projectId}>
* <Button>Create Feature</Button>
* </Permission>
*
* @example
* // Using render function to access permission state
* <Permission level="project" permission="CREATE_FEATURE" id={projectId}>
* {({ permission, isLoading }) => (
* <Button disabled={!permission || isLoading}>Create Feature</Button>
* )}
* </Permission>
*
* @example
* // With tooltip on permission denial
* <Permission
* level="project"
* permission="CREATE_FEATURE"
* id={projectId}
* showTooltip
* permissionName="Create Features"
* >
* <Button>Create Feature</Button>
* </Permission>
*
* @example
* // With fallback content
* <Permission
* level="project"
* permission="DELETE_FEATURE"
* id={projectId}
* fallback={<Text>You don't have permission to delete features</Text>}
* >
* <Button>Delete Feature</Button>
* </Permission>
*
* @example
* // With tag-based permissions
* <Permission
* level="project"
* permission="UPDATE_FEATURE"
* id={projectId}
* tags={[tagId1, tagId2]}
* >
* <Button>Update Feature</Button>
* </Permission>
*/
const Permission: FC<PermissionType> = ({
children,
fallback,
id,
level,
permission,
permissionName,
showTooltip = false,
tags,
}) => {
const { isLoading, permission: hasPermission } = useHasPermission({
Expand All @@ -58,14 +151,39 @@ const Permission: FC<PermissionType> = ({
permission,
tags,
})
return (
<>
{children({
isLoading,
permission: hasPermission || AccountStore.isAdmin(),
}) || null}
</>
)

const finalPermission = hasPermission || AccountStore.isAdmin()

if (typeof children === 'function') {
const renderedChildren = children({
isLoading,
permission: finalPermission,
})

if (finalPermission || !showTooltip) {
return <>{renderedChildren || null}</>
}

return Utils.renderWithPermission(
finalPermission,
permissionName || Constants.projectPermissions(permission),
renderedChildren,
)
}

if (finalPermission) {
return <>{children}</>
}

if (showTooltip) {
return Utils.renderWithPermission(
finalPermission,
permissionName || Constants.projectPermissions(permission),
children,
)
}

return <>{fallback || null}</>
}

export default Permission
Loading
Loading