From dcda1e905da5ab720895c11340fe44363f0c9a1a Mon Sep 17 00:00:00 2001 From: ephraimduncan Date: Tue, 4 Aug 2026 13:14:11 +0000 Subject: [PATCH] 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. --- .../tables/templates-table-view-filter.tsx | 39 ++++++++++++ .../t.$teamUrl+/templates._index.tsx | 61 +++++-------------- .../app/utils/templates-search-params.ts | 15 +++++ .../templates/organisation-templates.spec.ts | 27 ++++---- 4 files changed, 83 insertions(+), 59 deletions(-) create mode 100644 apps/remix/app/components/tables/templates-table-view-filter.tsx create mode 100644 apps/remix/app/utils/templates-search-params.ts diff --git a/apps/remix/app/components/tables/templates-table-view-filter.tsx b/apps/remix/app/components/tables/templates-table-view-filter.tsx new file mode 100644 index 000000000..6038b22bc --- /dev/null +++ b/apps/remix/app/components/tables/templates-table-view-filter.tsx @@ -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: Team }, + { value: 'organisation', label: Organisation }, +]; + +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 ( + View} + value={view} + onChange={onChange} + options={VIEW_OPTIONS} + testId="templates-table-view-filter" + /> + ); +}; diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates._index.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates._index.tsx index 81070e43e..236b5c5f6 100644 --- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates._index.tsx +++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates._index.tsx @@ -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( @@ -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 (
@@ -112,26 +100,9 @@ export default function TemplatesPage() {
- {showOrgTab && ( -
- - - - Team - - - Organisation - - - + {showOrgFilter && ( +
+
)} diff --git a/apps/remix/app/utils/templates-search-params.ts b/apps/remix/app/utils/templates-search-params.ts new file mode 100644 index 000000000..1222921af --- /dev/null +++ b/apps/remix/app/utils/templates-search-params.ts @@ -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, +}; diff --git a/packages/app-tests/e2e/templates/organisation-templates.spec.ts b/packages/app-tests/e2e/templates/organisation-templates.spec.ts index 2322cf04d..dc846a067 100644 --- a/packages/app-tests/e2e/templates/organisation-templates.spec.ts +++ b/packages/app-tests/e2e/templates/organisation-templates.spec.ts @@ -98,10 +98,10 @@ const trpcMutation = async (page: Page, procedure: string, input: Record { - 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.