From d8a4b680592553d110b1ad7a0967744babf707e1 Mon Sep 17 00:00:00 2001 From: Rishabh Mishra Date: Tue, 8 Oct 2024 11:52:02 +0530 Subject: [PATCH] feat(sdk): add custom screens to IAM dialog (#791) * refactor: mode default routes to seperate file * feat: add props for custom component * feat: add custom components link in sidebar * feat: register custom components to the router * feat: export layout component for custom screens * refactor: rename props and interface to custom Screen * Update sdks/js/packages/core/react/components/organization/profile.tsx Co-authored-by: Gaurav Singh --------- Co-authored-by: Gaurav Singh --- .../core/react/components/Layout/index.tsx | 20 ++ .../react/components/Layout/layout.module.css | 10 + .../react/components/organization/profile.tsx | 302 +--------------- .../react/components/organization/routes.tsx | 325 ++++++++++++++++++ .../organization/sidebar/helpers.ts | 36 +- .../components/organization/sidebar/index.tsx | 23 +- sdks/js/packages/core/react/index.ts | 1 + 7 files changed, 417 insertions(+), 300 deletions(-) create mode 100644 sdks/js/packages/core/react/components/Layout/index.tsx create mode 100644 sdks/js/packages/core/react/components/Layout/layout.module.css create mode 100644 sdks/js/packages/core/react/components/organization/routes.tsx diff --git a/sdks/js/packages/core/react/components/Layout/index.tsx b/sdks/js/packages/core/react/components/Layout/index.tsx new file mode 100644 index 000000000..c7e308523 --- /dev/null +++ b/sdks/js/packages/core/react/components/Layout/index.tsx @@ -0,0 +1,20 @@ +import { Flex, Text } from '@raystack/apsara'; +import { PropsWithChildren } from 'react'; +import styles from './layout.module.css'; + +interface LayoutProps { + title: string; +} + +export function Layout({ title, children }: PropsWithChildren) { + return ( + + + {title} + + + {children} + + + ); +} diff --git a/sdks/js/packages/core/react/components/Layout/layout.module.css b/sdks/js/packages/core/react/components/Layout/layout.module.css new file mode 100644 index 000000000..68edd8346 --- /dev/null +++ b/sdks/js/packages/core/react/components/Layout/layout.module.css @@ -0,0 +1,10 @@ +.header { + padding: 16px 48px; + border-bottom: 1px solid var(--border-subtle); + gap: 8px; +} + +.container { + padding: 32px 48px; + overflow: scroll; +} diff --git a/sdks/js/packages/core/react/components/organization/profile.tsx b/sdks/js/packages/core/react/components/organization/profile.tsx index b2f714355..a87b8ce25 100644 --- a/sdks/js/packages/core/react/components/organization/profile.tsx +++ b/sdks/js/packages/core/react/components/organization/profile.tsx @@ -1,298 +1,22 @@ -import { Flex } from '@raystack/apsara'; -import { useCallback, useEffect } from 'react'; import { - Outlet, RouterProvider, - createRoute, createMemoryHistory, - createRootRouteWithContext, - useRouteContext, createRouter } from '@tanstack/react-router'; -import { Toaster } from 'sonner'; -import { useFrontier } from '~/react/contexts/FrontierContext'; -import Domain from './domain'; -import { AddDomain } from './domain/add-domain'; -import { VerifyDomain } from './domain/verify-domain'; -import GeneralSetting from './general'; -import { DeleteOrganization } from './general/delete'; -import WorkspaceMembers from './members'; -import { InviteMember } from './members/invite'; -import UserPreferences from './preferences'; - -import { default as WorkspaceProjects } from './project'; -import { AddProject } from './project/add'; -import { DeleteProject } from './project/delete'; -import { ProjectPage } from './project/project'; - -import WorkspaceSecurity from './security'; -import { Sidebar } from './sidebar'; -import WorkspaceTeams from './teams'; -import { AddTeam } from './teams/add'; -import { DeleteTeam } from './teams/delete'; -import { TeamPage } from './teams/team'; -import { UserSetting } from './user'; -import { SkeletonTheme } from 'react-loading-skeleton'; -import { InviteTeamMembers } from './teams/members/invite'; -import { DeleteDomain } from './domain/delete'; -import Billing from './billing'; -import Tokens from './tokens'; -import { ConfirmCycleSwitch } from './billing/cycle-switch'; -import Plans from './plans'; -import ConfirmPlanChange from './plans/confirm-change'; -import MemberRemoveConfirm from './members/MemberRemoveConfirm'; - -interface OrganizationProfileProps { - organizationId: string; - defaultRoute?: string; - showBilling?: boolean; - showTokens?: boolean; - showPreferences?: boolean; - hideToast?: boolean; -} - -type RouterContext = Pick< - OrganizationProfileProps, - | 'organizationId' - | 'showBilling' - | 'showTokens' - | 'hideToast' - | 'showPreferences' ->; - -const RootRouter = () => { - const { organizationId, hideToast } = useRouteContext({ from: '__root__' }); - const { client, setActiveOrganization, setIsActiveOrganizationLoading } = - useFrontier(); - - const fetchOrganization = useCallback(async () => { - try { - setIsActiveOrganizationLoading(true); - const resp = await client?.frontierServiceGetOrganization(organizationId); - const organization = resp?.data.organization; - setActiveOrganization(organization); - } catch (err) { - console.error(err); - } finally { - setIsActiveOrganizationLoading(false); - } - }, [ - client, - organizationId, - setActiveOrganization, - setIsActiveOrganizationLoading - ]); - - useEffect(() => { - if (organizationId) { - fetchOrganization(); - } else { - setActiveOrganization(undefined); - } - }, [organizationId, fetchOrganization, setActiveOrganization]); - - const visibleToasts = hideToast ? 0 : 1; - - return ( - - - - - - - - ); -}; - -const rootRoute = createRootRouteWithContext()({ - component: RootRouter -}); -const indexRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/', - component: GeneralSetting -}); - -const deleteOrgRoute = createRoute({ - getParentRoute: () => indexRoute, - path: '/delete', - component: DeleteOrganization -}); - -const securityRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/security', - component: WorkspaceSecurity -}); - -const membersRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/members', - component: WorkspaceMembers -}); - -const inviteMemberRoute = createRoute({ - getParentRoute: () => membersRoute, - path: '/modal', - component: InviteMember -}); - -const removeMemberRoute = createRoute({ - getParentRoute: () => membersRoute, - path: '/remove-member/$memberId/$invited', - component: MemberRemoveConfirm -}); - -const teamsRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/teams', - component: WorkspaceTeams -}); - -const addTeamRoute = createRoute({ - getParentRoute: () => teamsRoute, - path: '/modal', - component: AddTeam -}); - -const domainsRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/domains', - component: Domain -}); - -const verifyDomainRoute = createRoute({ - getParentRoute: () => domainsRoute, - path: '/$domainId/verify', - component: VerifyDomain -}); - -const deleteDomainRoute = createRoute({ - getParentRoute: () => domainsRoute, - path: '/$domainId/delete', - component: DeleteDomain -}); - -const addDomainRoute = createRoute({ - getParentRoute: () => domainsRoute, - path: '/modal', - component: AddDomain -}); - -const teamRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/teams/$teamId', - component: TeamPage -}); - -const inviteTeamMembersRoute = createRoute({ - getParentRoute: () => teamRoute, - path: '/invite', - component: InviteTeamMembers -}); - -const deleteTeamRoute = createRoute({ - getParentRoute: () => teamRoute, - path: '/delete', - component: DeleteTeam -}); - -const projectsRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/projects', - component: WorkspaceProjects -}); - -const addProjectRoute = createRoute({ - getParentRoute: () => projectsRoute, - path: '/modal', - component: AddProject -}); - -const projectPageRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/projects/$projectId', - component: ProjectPage -}); - -const deleteProjectRoute = createRoute({ - getParentRoute: () => projectPageRoute, - path: '/delete', - component: DeleteProject -}); - -const profileRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/profile', - component: UserSetting -}); - -const preferencesRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/preferences', - component: UserPreferences -}); - -const billingRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/billing', - component: Billing -}); - -const switchBillingCycleModalRoute = createRoute({ - getParentRoute: () => billingRoute, - path: '/cycle-switch/$planId', - component: ConfirmCycleSwitch -}); - -const plansRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/plans', - component: Plans -}); - -const planDowngradeRoute = createRoute({ - getParentRoute: () => plansRoute, - path: '/confirm-change/$planId', - component: ConfirmPlanChange -}); - -const tokensRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/tokens', - component: Tokens -}); - -const routeTree = rootRoute.addChildren([ - indexRoute.addChildren([deleteOrgRoute]), - securityRoute, - membersRoute.addChildren([inviteMemberRoute, removeMemberRoute]), - teamsRoute.addChildren([addTeamRoute]), - domainsRoute.addChildren([ - addDomainRoute, - verifyDomainRoute, - deleteDomainRoute - ]), - teamRoute.addChildren([deleteTeamRoute, inviteTeamMembersRoute]), - projectsRoute.addChildren([addProjectRoute]), - projectPageRoute.addChildren([deleteProjectRoute]), - profileRoute, - preferencesRoute, - billingRoute.addChildren([switchBillingCycleModalRoute]), - plansRoute.addChildren([planDowngradeRoute]), - tokensRoute -]); +import { + getCustomRoutes, + getRootTree, + OrganizationProfileProps +} from './routes'; const router = createRouter({ - routeTree, + routeTree: getRootTree({}), context: { organizationId: '', showBilling: false, showTokens: false, - showPreferences: false + showPreferences: false, + customRoutes: { Organization: [], User: [] } } }); @@ -302,12 +26,17 @@ export const OrganizationProfile = ({ showBilling = false, showTokens = false, showPreferences = false, - hideToast = false + hideToast = false, + customScreens = [] }: OrganizationProfileProps) => { const memoryHistory = createMemoryHistory({ initialEntries: [defaultRoute] }); + const customRoutes = getCustomRoutes(customScreens); + + const routeTree = getRootTree({ customScreens }); + const memoryRouter = createRouter({ routeTree, history: memoryHistory, @@ -316,7 +45,8 @@ export const OrganizationProfile = ({ showBilling, showTokens, hideToast, - showPreferences + showPreferences, + customRoutes } }); return ; diff --git a/sdks/js/packages/core/react/components/organization/routes.tsx b/sdks/js/packages/core/react/components/organization/routes.tsx new file mode 100644 index 000000000..75f32fea4 --- /dev/null +++ b/sdks/js/packages/core/react/components/organization/routes.tsx @@ -0,0 +1,325 @@ +import { useCallback, useEffect } from 'react'; +import { + Outlet, + createRoute, + createRootRouteWithContext, + useRouteContext, + RouteComponent +} from '@tanstack/react-router'; + +import { Flex } from '@raystack/apsara'; + +import { Toaster } from 'sonner'; +import { useFrontier } from '~/react/contexts/FrontierContext'; +import Domain from './domain'; +import { AddDomain } from './domain/add-domain'; +import { VerifyDomain } from './domain/verify-domain'; +import GeneralSetting from './general'; +import { DeleteOrganization } from './general/delete'; +import WorkspaceMembers from './members'; +import { InviteMember } from './members/invite'; +import UserPreferences from './preferences'; + +import { default as WorkspaceProjects } from './project'; +import { AddProject } from './project/add'; +import { DeleteProject } from './project/delete'; +import { ProjectPage } from './project/project'; + +import WorkspaceSecurity from './security'; +import { Sidebar } from './sidebar'; +import WorkspaceTeams from './teams'; +import { AddTeam } from './teams/add'; +import { DeleteTeam } from './teams/delete'; +import { TeamPage } from './teams/team'; +import { UserSetting } from './user'; +import { SkeletonTheme } from 'react-loading-skeleton'; +import { InviteTeamMembers } from './teams/members/invite'; +import { DeleteDomain } from './domain/delete'; +import Billing from './billing'; +import Tokens from './tokens'; +import { ConfirmCycleSwitch } from './billing/cycle-switch'; +import Plans from './plans'; +import ConfirmPlanChange from './plans/confirm-change'; +import MemberRemoveConfirm from './members/MemberRemoveConfirm'; + +export interface CustomScreen { + name: string; + path: string; + category: 'Organization' | 'User'; + component: RouteComponent; +} + +export interface OrganizationProfileProps { + organizationId: string; + defaultRoute?: string; + showBilling?: boolean; + showTokens?: boolean; + showPreferences?: boolean; + hideToast?: boolean; + customScreens?: CustomScreen[]; +} + +export interface CustomRoutes { + Organization: Pick[]; + User: Pick[]; +} + +type RouterContext = Pick< + OrganizationProfileProps, + | 'organizationId' + | 'showBilling' + | 'showTokens' + | 'hideToast' + | 'showPreferences' +> & { customRoutes: CustomRoutes }; + +export function getCustomRoutes(customScreens: CustomScreen[] = []) { + return ( + customScreens?.reduce( + (acc: CustomRoutes, { name, category, path }) => { + acc[category].push({ name, path }); + return acc; + }, + { Organization: [], User: [] } + ) || { Organization: [], User: [] } + ); +} + +const RootRouter = () => { + const { organizationId, hideToast } = useRouteContext({ from: '__root__' }); + const { client, setActiveOrganization, setIsActiveOrganizationLoading } = + useFrontier(); + + const fetchOrganization = useCallback(async () => { + try { + setIsActiveOrganizationLoading(true); + const resp = await client?.frontierServiceGetOrganization(organizationId); + const organization = resp?.data.organization; + setActiveOrganization(organization); + } catch (err) { + console.error(err); + } finally { + setIsActiveOrganizationLoading(false); + } + }, [ + client, + organizationId, + setActiveOrganization, + setIsActiveOrganizationLoading + ]); + + useEffect(() => { + if (organizationId) { + fetchOrganization(); + } else { + setActiveOrganization(undefined); + } + }, [organizationId, fetchOrganization, setActiveOrganization]); + + const visibleToasts = hideToast ? 0 : 1; + + return ( + + + + + + + + ); +}; + +const rootRoute = createRootRouteWithContext()({ + component: RootRouter +}); +const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: GeneralSetting +}); + +const deleteOrgRoute = createRoute({ + getParentRoute: () => indexRoute, + path: '/delete', + component: DeleteOrganization +}); + +const securityRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/security', + component: WorkspaceSecurity +}); + +const membersRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/members', + component: WorkspaceMembers +}); + +const inviteMemberRoute = createRoute({ + getParentRoute: () => membersRoute, + path: '/modal', + component: InviteMember +}); + +const removeMemberRoute = createRoute({ + getParentRoute: () => membersRoute, + path: '/remove-member/$memberId/$invited', + component: MemberRemoveConfirm +}); + +const teamsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/teams', + component: WorkspaceTeams +}); + +const addTeamRoute = createRoute({ + getParentRoute: () => teamsRoute, + path: '/modal', + component: AddTeam +}); + +const domainsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/domains', + component: Domain +}); + +const verifyDomainRoute = createRoute({ + getParentRoute: () => domainsRoute, + path: '/$domainId/verify', + component: VerifyDomain +}); + +const deleteDomainRoute = createRoute({ + getParentRoute: () => domainsRoute, + path: '/$domainId/delete', + component: DeleteDomain +}); + +const addDomainRoute = createRoute({ + getParentRoute: () => domainsRoute, + path: '/modal', + component: AddDomain +}); + +const teamRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/teams/$teamId', + component: TeamPage +}); + +const inviteTeamMembersRoute = createRoute({ + getParentRoute: () => teamRoute, + path: '/invite', + component: InviteTeamMembers +}); + +const deleteTeamRoute = createRoute({ + getParentRoute: () => teamRoute, + path: '/delete', + component: DeleteTeam +}); + +const projectsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/projects', + component: WorkspaceProjects +}); + +const addProjectRoute = createRoute({ + getParentRoute: () => projectsRoute, + path: '/modal', + component: AddProject +}); + +const projectPageRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/projects/$projectId', + component: ProjectPage +}); + +const deleteProjectRoute = createRoute({ + getParentRoute: () => projectPageRoute, + path: '/delete', + component: DeleteProject +}); + +const profileRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/profile', + component: UserSetting +}); + +const preferencesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/preferences', + component: UserPreferences +}); + +const billingRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/billing', + component: Billing +}); + +const switchBillingCycleModalRoute = createRoute({ + getParentRoute: () => billingRoute, + path: '/cycle-switch/$planId', + component: ConfirmCycleSwitch +}); + +const plansRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/plans', + component: Plans +}); + +const planDowngradeRoute = createRoute({ + getParentRoute: () => plansRoute, + path: '/confirm-change/$planId', + component: ConfirmPlanChange +}); + +const tokensRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/tokens', + component: Tokens +}); + +interface getRootTreeOptions { + customScreens?: CustomScreen[]; +} + +export function getRootTree({ customScreens = [] }: getRootTreeOptions) { + return rootRoute.addChildren([ + indexRoute.addChildren([deleteOrgRoute]), + securityRoute, + membersRoute.addChildren([inviteMemberRoute, removeMemberRoute]), + teamsRoute.addChildren([addTeamRoute]), + domainsRoute.addChildren([ + addDomainRoute, + verifyDomainRoute, + deleteDomainRoute + ]), + teamRoute.addChildren([deleteTeamRoute, inviteTeamMembersRoute]), + projectsRoute.addChildren([addProjectRoute]), + projectPageRoute.addChildren([deleteProjectRoute]), + profileRoute, + preferencesRoute, + billingRoute.addChildren([switchBillingCycleModalRoute]), + plansRoute.addChildren([planDowngradeRoute]), + tokensRoute, + ...customScreens.map(cc => + createRoute({ + path: cc.path, + component: cc.component, + getParentRoute: () => rootRoute + }) + ) + ]); +} diff --git a/sdks/js/packages/core/react/components/organization/sidebar/helpers.ts b/sdks/js/packages/core/react/components/organization/sidebar/helpers.ts index 3b9a6d8bb..baf9fb5fc 100644 --- a/sdks/js/packages/core/react/components/organization/sidebar/helpers.ts +++ b/sdks/js/packages/core/react/components/organization/sidebar/helpers.ts @@ -7,20 +7,34 @@ export type NavigationItemsTypes = { icon?: React.ReactNode; }; +type CustomRoutes = Array<{ name: string; path: string }>; + interface getOrganizationNavItemsOptions { showBilling?: boolean; showTokens?: boolean; canSeeBilling?: boolean; + customRoutes?: CustomRoutes; } interface getUserNavItemsOptions { showPreferences?: boolean; + customRoutes?: CustomRoutes; +} + +function getCustomRoutes(customRoutes: CustomRoutes = []) { + return ( + customRoutes?.map(r => ({ + name: r.name, + to: r.path, + show: true + })) || [] + ); } export const getOrganizationNavItems = ( options: getOrganizationNavItemsOptions = {} -) => - [ +) => { + const routes = [ { name: 'General', to: '/', @@ -61,10 +75,15 @@ export const getOrganizationNavItems = ( to: '/plans', show: options?.showBilling } - ].filter(nav => nav.show) as NavigationItemsTypes[]; + ]; + const customRoutes = getCustomRoutes(options?.customRoutes); + return [...routes, ...customRoutes].filter( + nav => nav.show + ) as NavigationItemsTypes[]; +}; -export const getUserNavItems = (options: getUserNavItemsOptions = {}) => - [ +export const getUserNavItems = (options: getUserNavItemsOptions = {}) => { + const routes = [ { name: 'Profile', to: '/profile', @@ -75,4 +94,9 @@ export const getUserNavItems = (options: getUserNavItemsOptions = {}) => to: '/preferences', show: options?.showPreferences } - ].filter(nav => nav.show) as NavigationItemsTypes[]; + ]; + const customRoutes = getCustomRoutes(options?.customRoutes); + return [...routes, ...customRoutes].filter( + nav => nav.show + ) as NavigationItemsTypes[]; +}; diff --git a/sdks/js/packages/core/react/components/organization/sidebar/index.tsx b/sdks/js/packages/core/react/components/organization/sidebar/index.tsx index c744de2c7..9a34d2574 100644 --- a/sdks/js/packages/core/react/components/organization/sidebar/index.tsx +++ b/sdks/js/packages/core/react/components/organization/sidebar/index.tsx @@ -21,10 +21,15 @@ import styles from './sidebar.module.css'; export const Sidebar = () => { const [search, setSearch] = useState(''); const routerState = useRouterState(); - const { organizationId, showBilling, showTokens, showPreferences } = - useRouteContext({ - from: '__root__' - }); + const { + organizationId, + showBilling, + showTokens, + showPreferences, + customRoutes + } = useRouteContext({ + from: '__root__' + }); const isActive = useCallback( (path: string) => @@ -64,17 +69,19 @@ export const Sidebar = () => { getOrganizationNavItems({ showBilling: showBilling, canSeeBilling: canSeeBilling, - showTokens: showTokens + showTokens: showTokens, + customRoutes: customRoutes.Organization }), - [showBilling, canSeeBilling, showTokens] + [showBilling, canSeeBilling, showTokens, customRoutes.Organization] ); const userNavItems = useMemo( () => getUserNavItems({ - showPreferences: showPreferences + showPreferences: showPreferences, + customRoutes: customRoutes.User }), - [] + [customRoutes.User, showPreferences] ); return ( diff --git a/sdks/js/packages/core/react/index.ts b/sdks/js/packages/core/react/index.ts index 9cedfcb4e..c3463399d 100644 --- a/sdks/js/packages/core/react/index.ts +++ b/sdks/js/packages/core/react/index.ts @@ -18,6 +18,7 @@ export { FrontierProvider } from './contexts/FrontierProvider'; export { Amount }; export { useTokens } from './hooks/useTokens'; +export { Layout } from './components/Layout'; export type { FrontierClientOptions,