mirror of
https://github.com/documenso/documenso.git
synced 2026-08-15 02:53:32 +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 { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
||||
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 { Trans } from '@lingui/react/macro';
|
||||
import { EnvelopeType, OrganisationType } from '@prisma/client';
|
||||
import { Bird } from 'lucide-react';
|
||||
import { parseAsStringLiteral, useQueryState } from 'nuqs';
|
||||
import { useQueryStates } from 'nuqs';
|
||||
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 { 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 { EnvelopesTableBulkActionBar } from '~/components/tables/envelopes-table-bulk-action-bar';
|
||||
import { TemplatesTable } from '~/components/tables/templates-table';
|
||||
import { TemplatesTableViewFilter } from '~/components/tables/templates-table-view-filter';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
|
||||
const TEMPLATE_VIEWS = ['team', 'organisation'] as const;
|
||||
import { templatesSearchParams } from '~/utils/templates-search-params';
|
||||
|
||||
export function meta() {
|
||||
return appMetaTags(msg`Templates`);
|
||||
@@ -39,15 +38,12 @@ export default function TemplatesPage() {
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const { folderId } = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [findTemplateSearchParams] = useQueryStates(templatesSearchParams, {
|
||||
history: 'push',
|
||||
});
|
||||
|
||||
const page = Number(searchParams.get('page')) || 1;
|
||||
const perPage = Number(searchParams.get('perPage')) || 10;
|
||||
|
||||
const [view, setView] = useQueryState('view', parseAsStringLiteral(TEMPLATE_VIEWS).withDefault('team'));
|
||||
|
||||
const isOrgView = view === 'organisation';
|
||||
const showOrgTab = organisation.type !== OrganisationType.PERSONAL;
|
||||
const isOrgView = findTemplateSearchParams.view === 'organisation';
|
||||
const showOrgFilter = organisation.type !== OrganisationType.PERSONAL;
|
||||
|
||||
// Scoped by team so selections made in one team never leak into another.
|
||||
const [rowSelection, setRowSelection] = useSessionStorage<RowSelectionState>(
|
||||
@@ -66,8 +62,8 @@ export default function TemplatesPage() {
|
||||
|
||||
const teamTemplatesQuery = trpc.template.findTemplates.useQuery(
|
||||
{
|
||||
page,
|
||||
perPage,
|
||||
page: findTemplateSearchParams.page ?? undefined,
|
||||
perPage: findTemplateSearchParams.perPage ?? undefined,
|
||||
folderId,
|
||||
},
|
||||
{
|
||||
@@ -77,8 +73,8 @@ export default function TemplatesPage() {
|
||||
|
||||
const orgTemplatesQuery = trpc.template.findOrganisationTemplates.useQuery(
|
||||
{
|
||||
page,
|
||||
perPage,
|
||||
page: findTemplateSearchParams.page ?? undefined,
|
||||
perPage: findTemplateSearchParams.perPage ?? undefined,
|
||||
},
|
||||
{
|
||||
enabled: isOrgView,
|
||||
@@ -87,14 +83,6 @@ export default function TemplatesPage() {
|
||||
|
||||
const activeQuery = isOrgView ? orgTemplatesQuery : teamTemplatesQuery;
|
||||
|
||||
const handleViewChange = (newView: string) => {
|
||||
if (newView !== 'team' && newView !== 'organisation') {
|
||||
return;
|
||||
}
|
||||
|
||||
void setView(newView === 'team' ? null : newView);
|
||||
};
|
||||
|
||||
return (
|
||||
<EnvelopeDropZoneWrapper type={EnvelopeType.TEMPLATE}>
|
||||
<div className="mx-auto max-w-screen-xl px-4 md:px-8">
|
||||
@@ -112,26 +100,9 @@ export default function TemplatesPage() {
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{showOrgTab && (
|
||||
<div className="mt-6">
|
||||
<Tabs value={view} onValueChange={handleViewChange} data-testid="template-view-tabs">
|
||||
<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>
|
||||
{showOrgFilter && (
|
||||
<div className="mt-6 flex flex-wrap items-center gap-x-2 gap-y-4">
|
||||
<TemplatesTableViewFilter />
|
||||
</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 };
|
||||
};
|
||||
|
||||
// ─── UI: Tab Visibility ──────────────────────────────────────────────────────
|
||||
// ─── UI: View Filter Visibility ──────────────────────────────────────────────
|
||||
|
||||
test.describe('Organisation Templates - UI Tabs', () => {
|
||||
test('should show Team/Organisation tabs for non-personal orgs', async ({ page }) => {
|
||||
test.describe('Organisation Templates - UI View Filter', () => {
|
||||
test('should show the view filter for non-personal orgs', async ({ page }) => {
|
||||
const { ownerA, teamA } = await seedOrgTemplateScenario();
|
||||
|
||||
await apiSignin({
|
||||
@@ -110,11 +110,10 @@ test.describe('Organisation Templates - UI Tabs', () => {
|
||||
redirectPath: `/t/${teamA.url}/templates`,
|
||||
});
|
||||
|
||||
await expect(page.getByTestId('template-tab-team')).toBeVisible();
|
||||
await expect(page.getByTestId('template-tab-organisation')).toBeVisible();
|
||||
await expect(page.getByTestId('templates-table-view-filter')).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 });
|
||||
|
||||
await apiSignin({
|
||||
@@ -123,15 +122,14 @@ test.describe('Organisation Templates - UI Tabs', () => {
|
||||
redirectPath: `/t/${team.url}/templates`,
|
||||
});
|
||||
|
||||
await expect(page.getByTestId('template-tab-team')).not.toBeVisible();
|
||||
await expect(page.getByTestId('template-tab-organisation')).not.toBeVisible();
|
||||
await expect(page.getByTestId('templates-table-view-filter')).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── UI: Listing Organisation Templates ──────────────────────────────────────
|
||||
|
||||
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();
|
||||
|
||||
await apiSignin({
|
||||
@@ -140,17 +138,18 @@ test.describe('Organisation Templates - Listing', () => {
|
||||
redirectPath: `/t/${teamB.url}/templates`,
|
||||
});
|
||||
|
||||
// Team tab should show 0 (memberB has no templates on teamB).
|
||||
await expect(page.getByTestId('template-tab-team')).toBeVisible();
|
||||
// Team view is active by default (memberB has no templates on teamB).
|
||||
await expect(page.getByTestId('templates-table-view-filter')).toBeVisible();
|
||||
|
||||
// Switch to Organisation tab.
|
||||
await page.getByTestId('template-tab-organisation').click();
|
||||
// Switch to the organisation view.
|
||||
await page.getByTestId('templates-table-view-filter').click();
|
||||
await page.getByRole('option', { name: 'Organisation' }).click();
|
||||
|
||||
// Should see the org template from teamA.
|
||||
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();
|
||||
|
||||
// Create a private template on teamA — should NOT appear in org tab.
|
||||
|
||||
Reference in New Issue
Block a user