feat: dashboard: add org and project static placeholder routes (#3069)
### **PR Type** enhancement ___ ### **Description** - Introduced new components `SelectOrg` and `SelectOrgAndProject` for selecting organizations and projects, respectively. - Implemented filtering functionality for both organizations and projects. - Integrated loading indicators and error boundaries for better user experience. - Added navigation logic to handle routing to selected organization and project pages. - Updated redirect logic to accommodate new routes for organizations and projects. - Added new pages and index for organization and project selection. - Documented changes in a changeset file. ___ ### **Changes walkthrough** 📝 <table><thead><tr><th></th><th align="left">Relevant files</th></tr></thead><tbody><tr><td><strong>Enhancement</strong></td><td><details><summary>8 files</summary><table> <tr> <td> <details> <summary><strong>SelectOrg.tsx</strong><dd><code>Add SelectOrganizationAndProject component with filtering and </code><br><code>navigation</code></dd></summary> <hr> dashboard/src/components/common/SelectOrg/SelectOrg.tsx <li>Added a new component <code>SelectOrganizationAndProject</code> for selecting <br>organizations.<br> <li> Implemented filtering functionality for organizations.<br> <li> Integrated loading indicator and error boundary.<br> <li> Added navigation logic to organization pages.<br> </details> </td> <td><a href="https://github.com/nhost/nhost/pull/3069/files#diff-3d9046053de6cf89a71b2c8843435afbade4eacff8f38f57bd9dd40e81fc5ba0">+144/-0</a> </td> </tr> <tr> <td> <details> <summary><strong>index.ts</strong><dd><code>Export SelectOrg component</code> </dd></summary> <hr> dashboard/src/components/common/SelectOrg/index.ts - Exported `SelectOrg` component from `SelectOrg.tsx`. </details> </td> <td><a href="https://github.com/nhost/nhost/pull/3069/files#diff-13c3f09c9b9992210b030c77a795c895b7b5672a603fd6577547272f1b4292c3">+1/-0</a> </td> </tr> <tr> <td> <details> <summary><strong>SelectOrgAndProject.tsx</strong><dd><code>Add SelectOrganizationAndProject component with filtering and </code><br><code>navigation</code></dd></summary> <hr> dashboard/src/components/common/SelectOrgAndProject/SelectOrgAndProject.tsx <li>Added a new component <code>SelectOrganizationAndProject</code> for selecting <br>projects.<br> <li> Implemented filtering functionality for projects.<br> <li> Integrated loading indicator and error boundary.<br> <li> Added navigation logic to project pages.<br> </details> </td> <td><a href="https://github.com/nhost/nhost/pull/3069/files#diff-7d86c6e5bc51696bf1aa421c920e01a1447699456c37b025bdc407050c7b5613">+146/-0</a> </td> </tr> <tr> <td> <details> <summary><strong>index.ts</strong><dd><code>Export SelectOrgAndProject component</code> </dd></summary> <hr> dashboard/src/components/common/SelectOrgAndProject/index.ts <li>Exported <code>SelectOrgAndProject</code> component from <code>SelectOrgAndProject.tsx</code>.<br> </details> </td> <td><a href="https://github.com/nhost/nhost/pull/3069/files#diff-8b07aa6bcfe4996a7c46923856e713ecf3156fe6c2720b28efcadebd7fb1496f">+2/-0</a> </td> </tr> <tr> <td> <details> <summary><strong>useNotFoundRedirect.ts</strong><dd><code>Update redirect logic for new routes</code> </dd></summary> <hr> dashboard/src/features/projects/common/hooks/useNotFoundRedirect/useNotFoundRedirect.ts <li>Updated redirect logic to include new organization and project routes.<br> <br> </details> </td> <td><a href="https://github.com/nhost/nhost/pull/3069/files#diff-837279cf43199053bca09913f62c4af019063a2e8dc7bfb7643ec54b7cecd29d">+2/-0</a> </td> </tr> <tr> <td> <details> <summary><strong>[...slug].tsx</strong><dd><code>Add organization selection page</code> </dd></summary> <hr> dashboard/src/pages/orgs/_/[...slug].tsx - Added a new page for selecting organizations using `SelectOrg`. </details> </td> <td><a href="https://github.com/nhost/nhost/pull/3069/files#diff-3993ec4184ca06532310b26dcf40fb3fb5b79c78621fbb8c83b15b145331b3e6">+15/-0</a> </td> </tr> <tr> <td> <details> <summary><strong>[...slug].tsx</strong><dd><code>Add project selection page</code> </dd></summary> <hr> dashboard/src/pages/orgs/_/projects/_/[...slug].tsx <li>Added a new page for selecting projects using <code>SelectOrgAndProject</code>.<br> </details> </td> <td><a href="https://github.com/nhost/nhost/pull/3069/files#diff-e4e0c0ae58b0bb766af6983e44171470d085d9b15079450d788ffe0ab34440ae">+15/-0</a> </td> </tr> <tr> <td> <details> <summary><strong>index.tsx</strong><dd><code>Add index page for project selection</code> </dd></summary> <hr> dashboard/src/pages/orgs/_/projects/_/index.tsx <li>Added a new index page for project selection using <br><code>SelectOrgAndProject</code>.<br> </details> </td> <td><a href="https://github.com/nhost/nhost/pull/3069/files#diff-f1903b9c41e81033add23bed91df48b3e2c485802187b160a87d2d6e2caef507">+14/-0</a> </td> </tr> </table></details></td></tr><tr><td><strong>Documentation</strong></td><td><details><summary>1 files</summary><table> <tr> <td> <details> <summary><strong>thin-pants-battle.md</strong><dd><code>Document org and project placeholder feature</code> </dd></summary> <hr> .changeset/thin-pants-battle.md - Documented the addition of organization and project placeholders. </details> </td> <td><a href="https://github.com/nhost/nhost/pull/3069/files#diff-74b67093a68ccc2b180504af0ce16b3404f16de81bd5200d15e066bda7345038">+5/-0</a> </td> </tr> </table></details></td></tr></tr></tbody></table> ___ > 💡 **PR-Agent usage**: Comment `/help "your question"` on any pull request to receive relevant information
This commit is contained in:
5
.changeset/thin-pants-battle.md
Normal file
5
.changeset/thin-pants-battle.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@nhost/dashboard': minor
|
||||
---
|
||||
|
||||
Feat: add org and project placeholders
|
||||
137
dashboard/src/components/common/SelectOrg/SelectOrg.tsx
Normal file
137
dashboard/src/components/common/SelectOrg/SelectOrg.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
|
||||
import { RetryableErrorBoundary } from '@/components/presentational/RetryableErrorBoundary';
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { Input } from '@/components/ui/v2/Input';
|
||||
import { List } from '@/components/ui/v2/List';
|
||||
import { ListItem } from '@/components/ui/v2/ListItem';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { useOrgs } from '@/features/orgs/projects/hooks/useOrgs';
|
||||
import { } from '@/utils/__generated__/graphql';
|
||||
import { Divider } from '@mui/material';
|
||||
import debounce from 'lodash.debounce';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/router';
|
||||
import type { ChangeEvent } from 'react';
|
||||
import { Fragment, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
export default function SelectOrganizationAndProject() {
|
||||
const { orgs, loading } = useOrgs();
|
||||
const router = useRouter();
|
||||
|
||||
const organizations = orgs.map((org) => ({
|
||||
name: org.name,
|
||||
value: `/orgs/${org.slug}`,
|
||||
}));
|
||||
|
||||
const [filter, setFilter] = useState('');
|
||||
|
||||
const handleFilterChange = useMemo(
|
||||
() =>
|
||||
debounce((event: ChangeEvent<HTMLInputElement>) => {
|
||||
setFilter(event.target.value);
|
||||
}, 200),
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => () => handleFilterChange.cancel(), [handleFilterChange]);
|
||||
|
||||
const goToOrgPage = async (org: {
|
||||
name: string;
|
||||
value: string;
|
||||
}) => {
|
||||
const { slug } = router.query;
|
||||
await router.push({
|
||||
pathname: `${org.value}/${
|
||||
(() => {
|
||||
if (!slug) {
|
||||
return '';
|
||||
}
|
||||
return Array.isArray(slug) ? slug.join('/') : slug;
|
||||
})()
|
||||
}`,
|
||||
});
|
||||
};
|
||||
|
||||
const orgsToDisplay = filter
|
||||
? organizations.filter((org) =>
|
||||
org.name.toLowerCase().includes(filter.toLowerCase()),
|
||||
)
|
||||
: organizations;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex w-full justify-center">
|
||||
<ActivityIndicator
|
||||
delay={500}
|
||||
label="Loading organizations..."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-start w-full h-full px-5 py-4 mx-auto bg-background">
|
||||
<div className="mx-auto flex h-full w-full max-w-[760px] flex-col gap-4 py-6 sm:py-14">
|
||||
<Text variant="h2" component="h1" className="">
|
||||
Select an Organization
|
||||
</Text>
|
||||
|
||||
<div>
|
||||
<div className="mb-2 flex w-full">
|
||||
<Input
|
||||
placeholder="Search..."
|
||||
onChange={handleFilterChange}
|
||||
fullWidth
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<RetryableErrorBoundary>
|
||||
{orgsToDisplay.length === 0 ? (
|
||||
<Box className="h-import py-2">
|
||||
<Text variant="subtitle2">No results found.</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<List className="h-import overflow-y-auto">
|
||||
{orgsToDisplay.map((org, index) => (
|
||||
<Fragment key={org.value}>
|
||||
<ListItem.Root
|
||||
className="grid grid-flow-col justify-start gap-2 py-2.5"
|
||||
secondaryAction={
|
||||
<Button
|
||||
variant="borderless"
|
||||
color="primary"
|
||||
onClick={() => goToOrgPage(org)}
|
||||
>
|
||||
Select
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<ListItem.Avatar>
|
||||
<span className="inline-block h-6 w-6 overflow-hidden rounded-md">
|
||||
<Image
|
||||
src="/logos/new.svg"
|
||||
alt="Nhost Logo"
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
</span>
|
||||
</ListItem.Avatar>
|
||||
<ListItem.Text
|
||||
primary={org.name}
|
||||
secondary={`${org.name} / ${org.name}`}
|
||||
/>
|
||||
</ListItem.Root>
|
||||
|
||||
{index < orgs.length - 1 && <Divider component="li" />}
|
||||
</Fragment>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
</RetryableErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1
dashboard/src/components/common/SelectOrg/index.ts
Normal file
1
dashboard/src/components/common/SelectOrg/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as SelectOrg } from './SelectOrg';
|
||||
@@ -0,0 +1,141 @@
|
||||
|
||||
import { RetryableErrorBoundary } from '@/components/presentational/RetryableErrorBoundary';
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { Input } from '@/components/ui/v2/Input';
|
||||
import { List } from '@/components/ui/v2/List';
|
||||
import { ListItem } from '@/components/ui/v2/ListItem';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { useOrgs } from '@/features/orgs/projects/hooks/useOrgs';
|
||||
import { } from '@/utils/__generated__/graphql';
|
||||
import { Divider } from '@mui/material';
|
||||
import debounce from 'lodash.debounce';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/router';
|
||||
import type { ChangeEvent } from 'react';
|
||||
import { Fragment, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
export default function SelectOrganizationAndProject() {
|
||||
const { orgs, loading } = useOrgs();
|
||||
const router = useRouter();
|
||||
|
||||
const projects = orgs.flatMap((org) =>
|
||||
org.apps.map((app) => ({
|
||||
organizationName: org.name,
|
||||
projectName: app.name,
|
||||
value: `/orgs/${org.slug}/projects/${app.subdomain}`,
|
||||
})),
|
||||
);
|
||||
|
||||
const [filter, setFilter] = useState('');
|
||||
|
||||
const handleFilterChange = useMemo(
|
||||
() =>
|
||||
debounce((event: ChangeEvent<HTMLInputElement>) => {
|
||||
setFilter(event.target.value);
|
||||
}, 200),
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => () => handleFilterChange.cancel(), [handleFilterChange]);
|
||||
|
||||
const goToProjectPage = async (project: {
|
||||
organizationName: string;
|
||||
projectName: string;
|
||||
value: string;
|
||||
}) => {
|
||||
const { slug } = router.query;
|
||||
await router.push({
|
||||
pathname: `${project.value}/${
|
||||
(() => {
|
||||
if (!slug) {
|
||||
return '';
|
||||
}
|
||||
return Array.isArray(slug) ? slug.join('/') : slug;
|
||||
})()
|
||||
}`,
|
||||
});
|
||||
};
|
||||
|
||||
const projectsToDisplay = filter
|
||||
? projects.filter((project) =>
|
||||
project.projectName.toLowerCase().includes(filter.toLowerCase()),
|
||||
)
|
||||
: projects;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex w-full justify-center">
|
||||
<ActivityIndicator
|
||||
delay={500}
|
||||
label="Loading organizations and projects..."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-start w-full h-full px-5 py-4 mx-auto bg-background">
|
||||
<div className="mx-auto flex h-full w-full max-w-[760px] flex-col gap-4 py-6 sm:py-14">
|
||||
<Text variant="h2" component="h1" className="">
|
||||
Select a Project
|
||||
</Text>
|
||||
|
||||
<div>
|
||||
<div className="mb-2 flex w-full">
|
||||
<Input
|
||||
placeholder="Search..."
|
||||
onChange={handleFilterChange}
|
||||
fullWidth
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<RetryableErrorBoundary>
|
||||
{projectsToDisplay.length === 0 ? (
|
||||
<Box className="h-import py-2">
|
||||
<Text variant="subtitle2">No results found.</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<List className="h-import overflow-y-auto">
|
||||
{projectsToDisplay.map((project, index) => (
|
||||
<Fragment key={project.value}>
|
||||
<ListItem.Root
|
||||
className="grid grid-flow-col justify-start gap-2 py-2.5"
|
||||
secondaryAction={
|
||||
<Button
|
||||
variant="borderless"
|
||||
color="primary"
|
||||
onClick={() => goToProjectPage(project)}
|
||||
>
|
||||
Select
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<ListItem.Avatar>
|
||||
<span className="inline-block h-6 w-6 overflow-hidden rounded-md">
|
||||
<Image
|
||||
src="/logos/new.svg"
|
||||
alt="Nhost Logo"
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
</span>
|
||||
</ListItem.Avatar>
|
||||
<ListItem.Text
|
||||
primary={project.projectName}
|
||||
secondary={`${project.organizationName} / ${project.projectName}`}
|
||||
/>
|
||||
</ListItem.Root>
|
||||
|
||||
{index < projects.length - 1 && <Divider component="li" />}
|
||||
</Fragment>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
</RetryableErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
|
||||
export { default as SelectOrgAndProject } from './SelectOrgAndProject';
|
||||
@@ -29,6 +29,8 @@ export default function useNotFoundRedirect() {
|
||||
router.pathname === '/account' ||
|
||||
router.pathname === '/support/ticket' ||
|
||||
router.pathname === '/run-one-click-install' ||
|
||||
router.pathname.includes('/orgs/_') ||
|
||||
router.pathname.includes('/orgs/_/projects/_') ||
|
||||
orgSlug ||
|
||||
(orgSlug && appSubdomain) ||
|
||||
// If we are on a valid workspace and project, we don't want to redirect to 404
|
||||
|
||||
15
dashboard/src/pages/orgs/_/[...slug].tsx
Normal file
15
dashboard/src/pages/orgs/_/[...slug].tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { SelectOrg } from '@/components/common/SelectOrg';
|
||||
import { AuthenticatedLayout } from '@/components/layout/AuthenticatedLayout';
|
||||
import { } from '@/utils/__generated__/graphql';
|
||||
import type { ReactElement } from 'react';
|
||||
|
||||
export default function SelectOrganization() {
|
||||
return <SelectOrg />
|
||||
}
|
||||
|
||||
SelectOrganization.getLayout = function getLayout(page: ReactElement) {
|
||||
return (
|
||||
<AuthenticatedLayout title="Select an Organization">{page}</AuthenticatedLayout>
|
||||
);
|
||||
};
|
||||
|
||||
15
dashboard/src/pages/orgs/_/projects/_/[...slug].tsx
Normal file
15
dashboard/src/pages/orgs/_/projects/_/[...slug].tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { AuthenticatedLayout } from '@/components/layout/AuthenticatedLayout';
|
||||
import { } from '@/utils/__generated__/graphql';
|
||||
import type { ReactElement } from 'react';
|
||||
|
||||
import { SelectOrgAndProject } from '@/components/common/SelectOrgAndProject';
|
||||
|
||||
export default function OrganizationAndProject() {
|
||||
return <SelectOrgAndProject />
|
||||
}
|
||||
|
||||
OrganizationAndProject.getLayout = function getLayout(page: ReactElement) {
|
||||
return (
|
||||
<AuthenticatedLayout title="Select a Project">{page}</AuthenticatedLayout>
|
||||
);
|
||||
};
|
||||
14
dashboard/src/pages/orgs/_/projects/_/index.tsx
Normal file
14
dashboard/src/pages/orgs/_/projects/_/index.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { SelectOrgAndProject } from '@/components/common/SelectOrgAndProject';
|
||||
import { AuthenticatedLayout } from '@/components/layout/AuthenticatedLayout';
|
||||
import { } from '@/utils/__generated__/graphql';
|
||||
import type { ReactElement } from 'react';
|
||||
|
||||
export default function SelectOrganizationAndProject() {
|
||||
return <SelectOrgAndProject />
|
||||
}
|
||||
|
||||
SelectOrganizationAndProject.getLayout = function getLayout(page: ReactElement) {
|
||||
return (
|
||||
<AuthenticatedLayout title="Select a Project">{page}</AuthenticatedLayout>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user