mirror of
https://github.com/documenso/documenso.git
synced 2026-08-24 15:22:26 +10:00
feat: replace template view tabs with a filter pill
This commit replaces the Team/Organisation tabs on the templates page with a FilterPill. The page uses the shared nuqs parsers for the view, page, and perPage URL parameters. The documents page (#3145) uses the same components. The e2e tests use the new filter test ID.
This commit is contained in:
@@ -0,0 +1,39 @@
|
|||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { Building2Icon } from 'lucide-react';
|
||||||
|
import { useQueryStates } from 'nuqs';
|
||||||
|
|
||||||
|
import { FilterPill } from '~/components/general/filter-pill';
|
||||||
|
import { TEMPLATES_VIEW_VALUES, templatesSearchParams } from '~/utils/templates-search-params';
|
||||||
|
|
||||||
|
const VIEW_OPTIONS = [
|
||||||
|
{ value: 'team', label: <Trans>Team</Trans> },
|
||||||
|
{ value: 'organisation', label: <Trans>Organisation</Trans> },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const TemplatesTableViewFilter = () => {
|
||||||
|
const [{ view }, setSearchParams] = useQueryStates(
|
||||||
|
{
|
||||||
|
view: templatesSearchParams.view,
|
||||||
|
page: templatesSearchParams.page,
|
||||||
|
},
|
||||||
|
{ history: 'push' },
|
||||||
|
);
|
||||||
|
|
||||||
|
const onChange = (newView: string | null) => {
|
||||||
|
void setSearchParams({
|
||||||
|
view: TEMPLATES_VIEW_VALUES.find((value) => value === newView) ?? null,
|
||||||
|
page: null,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FilterPill
|
||||||
|
icon={Building2Icon}
|
||||||
|
label={<Trans>View</Trans>}
|
||||||
|
value={view}
|
||||||
|
onChange={onChange}
|
||||||
|
options={VIEW_OPTIONS}
|
||||||
|
testId="templates-table-view-filter"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -6,14 +6,13 @@ import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/t
|
|||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
||||||
import type { RowSelectionState } from '@documenso/ui/primitives/data-table';
|
import type { RowSelectionState } from '@documenso/ui/primitives/data-table';
|
||||||
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { EnvelopeType, OrganisationType } from '@prisma/client';
|
import { EnvelopeType, OrganisationType } from '@prisma/client';
|
||||||
import { Bird } from 'lucide-react';
|
import { Bird } from 'lucide-react';
|
||||||
import { parseAsStringLiteral, useQueryState } from 'nuqs';
|
import { useQueryStates } from 'nuqs';
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { useParams, useSearchParams } from 'react-router';
|
import { useParams } from 'react-router';
|
||||||
|
|
||||||
import { EnvelopesBulkDeleteDialog } from '~/components/dialogs/envelopes-bulk-delete-dialog';
|
import { EnvelopesBulkDeleteDialog } from '~/components/dialogs/envelopes-bulk-delete-dialog';
|
||||||
import { EnvelopesBulkMoveDialog } from '~/components/dialogs/envelopes-bulk-move-dialog';
|
import { EnvelopesBulkMoveDialog } from '~/components/dialogs/envelopes-bulk-move-dialog';
|
||||||
@@ -21,10 +20,10 @@ import { EnvelopeDropZoneWrapper } from '~/components/general/envelope/envelope-
|
|||||||
import { FolderGrid } from '~/components/general/folder/folder-grid';
|
import { FolderGrid } from '~/components/general/folder/folder-grid';
|
||||||
import { EnvelopesTableBulkActionBar } from '~/components/tables/envelopes-table-bulk-action-bar';
|
import { EnvelopesTableBulkActionBar } from '~/components/tables/envelopes-table-bulk-action-bar';
|
||||||
import { TemplatesTable } from '~/components/tables/templates-table';
|
import { TemplatesTable } from '~/components/tables/templates-table';
|
||||||
|
import { TemplatesTableViewFilter } from '~/components/tables/templates-table-view-filter';
|
||||||
import { useCurrentTeam } from '~/providers/team';
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
import { appMetaTags } from '~/utils/meta';
|
import { appMetaTags } from '~/utils/meta';
|
||||||
|
import { templatesSearchParams } from '~/utils/templates-search-params';
|
||||||
const TEMPLATE_VIEWS = ['team', 'organisation'] as const;
|
|
||||||
|
|
||||||
export function meta() {
|
export function meta() {
|
||||||
return appMetaTags(msg`Templates`);
|
return appMetaTags(msg`Templates`);
|
||||||
@@ -39,15 +38,12 @@ export default function TemplatesPage() {
|
|||||||
const organisation = useCurrentOrganisation();
|
const organisation = useCurrentOrganisation();
|
||||||
|
|
||||||
const { folderId } = useParams();
|
const { folderId } = useParams();
|
||||||
const [searchParams] = useSearchParams();
|
const [findTemplateSearchParams] = useQueryStates(templatesSearchParams, {
|
||||||
|
history: 'push',
|
||||||
|
});
|
||||||
|
|
||||||
const page = Number(searchParams.get('page')) || 1;
|
const isOrgView = findTemplateSearchParams.view === 'organisation';
|
||||||
const perPage = Number(searchParams.get('perPage')) || 10;
|
const showOrgFilter = organisation.type !== OrganisationType.PERSONAL;
|
||||||
|
|
||||||
const [view, setView] = useQueryState('view', parseAsStringLiteral(TEMPLATE_VIEWS).withDefault('team'));
|
|
||||||
|
|
||||||
const isOrgView = view === 'organisation';
|
|
||||||
const showOrgTab = organisation.type !== OrganisationType.PERSONAL;
|
|
||||||
|
|
||||||
// Scoped by team so selections made in one team never leak into another.
|
// Scoped by team so selections made in one team never leak into another.
|
||||||
const [rowSelection, setRowSelection] = useSessionStorage<RowSelectionState>(
|
const [rowSelection, setRowSelection] = useSessionStorage<RowSelectionState>(
|
||||||
@@ -66,8 +62,8 @@ export default function TemplatesPage() {
|
|||||||
|
|
||||||
const teamTemplatesQuery = trpc.template.findTemplates.useQuery(
|
const teamTemplatesQuery = trpc.template.findTemplates.useQuery(
|
||||||
{
|
{
|
||||||
page,
|
page: findTemplateSearchParams.page ?? undefined,
|
||||||
perPage,
|
perPage: findTemplateSearchParams.perPage ?? undefined,
|
||||||
folderId,
|
folderId,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -77,8 +73,8 @@ export default function TemplatesPage() {
|
|||||||
|
|
||||||
const orgTemplatesQuery = trpc.template.findOrganisationTemplates.useQuery(
|
const orgTemplatesQuery = trpc.template.findOrganisationTemplates.useQuery(
|
||||||
{
|
{
|
||||||
page,
|
page: findTemplateSearchParams.page ?? undefined,
|
||||||
perPage,
|
perPage: findTemplateSearchParams.perPage ?? undefined,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
enabled: isOrgView,
|
enabled: isOrgView,
|
||||||
@@ -87,14 +83,6 @@ export default function TemplatesPage() {
|
|||||||
|
|
||||||
const activeQuery = isOrgView ? orgTemplatesQuery : teamTemplatesQuery;
|
const activeQuery = isOrgView ? orgTemplatesQuery : teamTemplatesQuery;
|
||||||
|
|
||||||
const handleViewChange = (newView: string) => {
|
|
||||||
if (newView !== 'team' && newView !== 'organisation') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
void setView(newView === 'team' ? null : newView);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EnvelopeDropZoneWrapper type={EnvelopeType.TEMPLATE}>
|
<EnvelopeDropZoneWrapper type={EnvelopeType.TEMPLATE}>
|
||||||
<div className="mx-auto max-w-screen-xl px-4 md:px-8">
|
<div className="mx-auto max-w-screen-xl px-4 md:px-8">
|
||||||
@@ -112,26 +100,9 @@ export default function TemplatesPage() {
|
|||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showOrgTab && (
|
{showOrgFilter && (
|
||||||
<div className="mt-6">
|
<div className="mt-6 flex flex-wrap items-center gap-x-2 gap-y-4">
|
||||||
<Tabs value={view} onValueChange={handleViewChange} data-testid="template-view-tabs">
|
<TemplatesTableViewFilter />
|
||||||
<TabsList>
|
|
||||||
<TabsTrigger
|
|
||||||
className="min-w-[60px] hover:text-foreground"
|
|
||||||
value="team"
|
|
||||||
data-testid="template-tab-team"
|
|
||||||
>
|
|
||||||
<Trans>Team</Trans>
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger
|
|
||||||
className="min-w-[60px] hover:text-foreground"
|
|
||||||
value="organisation"
|
|
||||||
data-testid="template-tab-organisation"
|
|
||||||
>
|
|
||||||
<Trans>Organisation</Trans>
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { parseAsInteger, parseAsStringLiteral } from 'nuqs';
|
||||||
|
|
||||||
|
export const TEMPLATES_VIEW_VALUES = ['team', 'organisation'] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared nuqs parsers for the templates page URL state.
|
||||||
|
*
|
||||||
|
* Used by the templates page and its filter components so every consumer
|
||||||
|
* parses and serialises the params identically.
|
||||||
|
*/
|
||||||
|
export const templatesSearchParams = {
|
||||||
|
view: parseAsStringLiteral(TEMPLATES_VIEW_VALUES),
|
||||||
|
page: parseAsInteger,
|
||||||
|
perPage: parseAsInteger,
|
||||||
|
};
|
||||||
@@ -98,10 +98,10 @@ const trpcMutation = async (page: Page, procedure: string, input: Record<string,
|
|||||||
return { res, json: res.ok() ? await res.json() : null };
|
return { res, json: res.ok() ? await res.json() : null };
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── UI: Tab Visibility ──────────────────────────────────────────────────────
|
// ─── UI: View Filter Visibility ──────────────────────────────────────────────
|
||||||
|
|
||||||
test.describe('Organisation Templates - UI Tabs', () => {
|
test.describe('Organisation Templates - UI View Filter', () => {
|
||||||
test('should show Team/Organisation tabs for non-personal orgs', async ({ page }) => {
|
test('should show the view filter for non-personal orgs', async ({ page }) => {
|
||||||
const { ownerA, teamA } = await seedOrgTemplateScenario();
|
const { ownerA, teamA } = await seedOrgTemplateScenario();
|
||||||
|
|
||||||
await apiSignin({
|
await apiSignin({
|
||||||
@@ -110,11 +110,10 @@ test.describe('Organisation Templates - UI Tabs', () => {
|
|||||||
redirectPath: `/t/${teamA.url}/templates`,
|
redirectPath: `/t/${teamA.url}/templates`,
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(page.getByTestId('template-tab-team')).toBeVisible();
|
await expect(page.getByTestId('templates-table-view-filter')).toBeVisible();
|
||||||
await expect(page.getByTestId('template-tab-organisation')).toBeVisible();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should not show tabs for personal organisations', async ({ page }) => {
|
test('should not show the view filter for personal organisations', async ({ page }) => {
|
||||||
const { user, team } = await seedUser({ isPersonalOrganisation: true });
|
const { user, team } = await seedUser({ isPersonalOrganisation: true });
|
||||||
|
|
||||||
await apiSignin({
|
await apiSignin({
|
||||||
@@ -123,15 +122,14 @@ test.describe('Organisation Templates - UI Tabs', () => {
|
|||||||
redirectPath: `/t/${team.url}/templates`,
|
redirectPath: `/t/${team.url}/templates`,
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(page.getByTestId('template-tab-team')).not.toBeVisible();
|
await expect(page.getByTestId('templates-table-view-filter')).not.toBeVisible();
|
||||||
await expect(page.getByTestId('template-tab-organisation')).not.toBeVisible();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── UI: Listing Organisation Templates ──────────────────────────────────────
|
// ─── UI: Listing Organisation Templates ──────────────────────────────────────
|
||||||
|
|
||||||
test.describe('Organisation Templates - Listing', () => {
|
test.describe('Organisation Templates - Listing', () => {
|
||||||
test('should list org templates from other teams under the Organisation tab', async ({ page }) => {
|
test('should list org templates from other teams under the organisation view', async ({ page }) => {
|
||||||
const { memberB, teamB, orgTemplate } = await seedOrgTemplateScenario();
|
const { memberB, teamB, orgTemplate } = await seedOrgTemplateScenario();
|
||||||
|
|
||||||
await apiSignin({
|
await apiSignin({
|
||||||
@@ -140,17 +138,18 @@ test.describe('Organisation Templates - Listing', () => {
|
|||||||
redirectPath: `/t/${teamB.url}/templates`,
|
redirectPath: `/t/${teamB.url}/templates`,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Team tab should show 0 (memberB has no templates on teamB).
|
// Team view is active by default (memberB has no templates on teamB).
|
||||||
await expect(page.getByTestId('template-tab-team')).toBeVisible();
|
await expect(page.getByTestId('templates-table-view-filter')).toBeVisible();
|
||||||
|
|
||||||
// Switch to Organisation tab.
|
// Switch to the organisation view.
|
||||||
await page.getByTestId('template-tab-organisation').click();
|
await page.getByTestId('templates-table-view-filter').click();
|
||||||
|
await page.getByRole('option', { name: 'Organisation' }).click();
|
||||||
|
|
||||||
// Should see the org template from teamA.
|
// Should see the org template from teamA.
|
||||||
await expect(page.getByText(orgTemplate.title)).toBeVisible();
|
await expect(page.getByText(orgTemplate.title)).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should not show private templates from other teams under Organisation tab', async ({ page }) => {
|
test('should not show private templates from other teams under the organisation view', async ({ page }) => {
|
||||||
const { ownerA, teamA, memberB, teamB } = await seedOrgTemplateScenario();
|
const { ownerA, teamA, memberB, teamB } = await seedOrgTemplateScenario();
|
||||||
|
|
||||||
// Create a private template on teamA — should NOT appear in org tab.
|
// Create a private template on teamA — should NOT appear in org tab.
|
||||||
|
|||||||
Reference in New Issue
Block a user