feat: add organisation template type (#2611)

This commit is contained in:
Ephraim Duncan
2026-03-17 01:29:34 +11:00
committed by GitHub
parent 943a0b50e3
commit 36bbd97514
41 changed files with 1702 additions and 279 deletions
@@ -67,6 +67,7 @@ export default function PublicProfilePage({ loaderData }: Route.ComponentProps)
const [isTooltipOpen, setIsTooltipOpen] = useState(false);
const { data } = trpc.template.findTemplates.useQuery({
type: TemplateType.PRIVATE,
perPage: 100,
});
@@ -82,8 +83,7 @@ export default function PublicProfilePage({ loaderData }: Route.ComponentProps)
const enabledPrivateDirectTemplates = useMemo(
() =>
(data?.data ?? []).filter(
(template): template is DirectTemplate =>
template.directLink?.enabled === true && template.type !== TemplateType.PUBLIC,
(template): template is DirectTemplate => template.directLink?.enabled === true,
),
[data],
);
@@ -143,7 +143,7 @@ export default function PublicProfilePage({ loaderData }: Route.ComponentProps)
<TooltipTrigger asChild>
<div
className={cn(
'text-muted-foreground/50 flex flex-row items-center justify-center space-x-2 text-xs',
'flex flex-row items-center justify-center space-x-2 text-xs text-muted-foreground/50',
{
'[&>*:first-child]:text-muted-foreground': !isPublicProfileVisible,
'[&>*:last-child]:text-muted-foreground': isPublicProfileVisible,
@@ -164,7 +164,7 @@ export default function PublicProfilePage({ loaderData }: Route.ComponentProps)
</div>
</TooltipTrigger>
<TooltipContent className="text-muted-foreground max-w-[40ch] space-y-2 py-2">
<TooltipContent className="max-w-[40ch] space-y-2 py-2 text-muted-foreground">
{isPublicProfileVisible ? (
<>
<p>
@@ -1,5 +1,5 @@
import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { DocumentSigningOrder, SigningStatus } from '@prisma/client';
import { ChevronLeft, LucideEdit } from 'lucide-react';
import { Link, useNavigate } from 'react-router';
@@ -37,19 +37,25 @@ import { useCurrentTeam } from '~/providers/team';
import type { Route } from './+types/templates.$id._index';
export default function TemplatePage({ params }: Route.ComponentProps) {
const { t } = useLingui();
const { user } = useSession();
const navigate = useNavigate();
const team = useCurrentTeam();
const {
data: envelope,
isLoading: isLoadingEnvelope,
isError: isErrorEnvelope,
} = trpc.envelope.get.useQuery({
envelopeId: params.id,
});
// Try fetching as a team template first; only fall back to the org endpoint if the team
// query has definitively failed (i.e. this template belongs to a sibling team).
// We disable retries on the team query so the org fallback kicks in immediately.
const teamTemplateQuery = trpc.envelope.get.useQuery({ envelopeId: params.id }, { retry: false });
const orgTemplateQuery = trpc.template.getOrganisationTemplateById.useQuery(
{ envelopeId: params.id },
{ enabled: teamTemplateQuery.isError, retry: false },
);
const envelope = teamTemplateQuery.data ?? orgTemplateQuery.data;
const isLoadingEnvelope =
teamTemplateQuery.isLoading ||
(teamTemplateQuery.isError && !orgTemplateQuery.isError && !orgTemplateQuery.data);
const isErrorEnvelope = teamTemplateQuery.isError && orgTemplateQuery.isError;
if (isLoadingEnvelope) {
return (
@@ -84,6 +90,7 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
const documentRootPath = formatDocumentsPath(team.url);
const templateRootPath = formatTemplatesPath(team.url);
const isOwnTeamTemplate = envelope.teamId === team?.id;
// Remap to fit the DocumentReadOnlyFields component.
const readOnlyFields = envelope.fields.map((field) => {
@@ -146,23 +153,27 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
</div>
<div className="mt-2 flex flex-row space-x-4 sm:mt-0 sm:self-end">
<TemplateDirectLinkDialog
templateId={mapSecondaryIdToTemplateId(envelope.secondaryId)}
directLink={envelope.directLink}
recipients={envelope.recipients}
/>
{isOwnTeamTemplate && (
<>
<TemplateDirectLinkDialog
templateId={mapSecondaryIdToTemplateId(envelope.secondaryId)}
directLink={envelope.directLink}
recipients={envelope.recipients}
/>
<TemplateBulkSendDialog
templateId={mapSecondaryIdToTemplateId(envelope.secondaryId)}
recipients={envelope.recipients}
/>
<TemplateBulkSendDialog
templateId={mapSecondaryIdToTemplateId(envelope.secondaryId)}
recipients={envelope.recipients}
/>
<Button className="w-full" asChild>
<Link to={`${templateRootPath}/${envelope.id}/edit`}>
<LucideEdit className="mr-1.5 h-3.5 w-3.5" />
<Trans>Edit Template</Trans>
</Link>
</Button>
<Button className="w-full" asChild>
<Link to={`${templateRootPath}/${envelope.id}/edit`}>
<LucideEdit className="mr-1.5 h-3.5 w-3.5" />
<Trans>Edit Template</Trans>
</Link>
</Button>
</>
)}
</div>
</div>
@@ -236,22 +247,28 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
<Trans>Template</Trans>
</h3>
<div>
<TemplatesTableActionDropdown
row={{
...envelope,
id: mapSecondaryIdToTemplateId(envelope.secondaryId),
envelopeId: envelope.id,
}}
teamId={team?.id}
templateRootPath={templateRootPath}
onDelete={async () => navigate(templateRootPath)}
/>
</div>
{isOwnTeamTemplate && (
<div>
<TemplatesTableActionDropdown
row={{
...envelope,
id: mapSecondaryIdToTemplateId(envelope.secondaryId),
envelopeId: envelope.id,
}}
teamId={team?.id}
templateRootPath={templateRootPath}
onDelete={async () => navigate(templateRootPath)}
/>
</div>
)}
</div>
<p className="mt-2 px-4 text-sm text-muted-foreground">
<Trans>Manage and view template</Trans>
{isOwnTeamTemplate ? (
<Trans>Manage and view template</Trans>
) : (
<Trans>View organisation template</Trans>
)}
</p>
<div className="mt-4 border-t px-4 pt-4">
@@ -278,6 +295,7 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
recipients={envelope.recipients}
envelopeId={envelope.id}
templateRootPath={templateRootPath}
readOnly={!isOwnTeamTemplate}
/>
{/* Recent activity section. */}
@@ -14,6 +14,7 @@ import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { getOrganisationTemplateById } from '@documenso/lib/server-only/template/get-organisation-template-by-id';
import { Button } from '@documenso/ui/primitives/button';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
@@ -43,18 +44,36 @@ export async function loader({ request, params }: Route.LoaderArgs) {
teamUrl: params.teamUrl,
});
// Try the team endpoint first, then fall back to the org endpoint.
const envelope = await getEnvelopeById({
id: {
type: 'templateId',
id: templateId,
},
id: { type: 'templateId', id: templateId },
type: EnvelopeType.TEMPLATE,
userId: user.id,
teamId: team.id,
}).catch((err) => {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.NOT_FOUND) {
if (error.code === AppErrorCode.NOT_FOUND || error.code === AppErrorCode.UNAUTHORIZED) {
return null;
}
throw err;
});
if (envelope) {
const url = new URL(request.url);
throw redirect(url.pathname.replace(`/templates/${id}`, `/templates/${envelope.id}`));
}
const orgEnvelope = await getOrganisationTemplateById({
id: { type: 'templateId', id: templateId },
userId: user.id,
teamId: team.id,
}).catch((err) => {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.NOT_FOUND || error.code === AppErrorCode.UNAUTHORIZED) {
throw new Response('Not Found', { status: 404 });
}
@@ -63,7 +82,7 @@ export async function loader({ request, params }: Route.LoaderArgs) {
const url = new URL(request.url);
throw redirect(url.pathname.replace(`/templates/${id}`, `/templates/${envelope.id}`));
throw redirect(url.pathname.replace(`/templates/${id}`, `/templates/${orgEnvelope.id}`));
}
}
@@ -1,17 +1,20 @@
import { useMemo, useState } from 'react';
import { Trans } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
import { EnvelopeType, OrganisationType } from '@prisma/client';
import { Bird } from 'lucide-react';
import { parseAsStringLiteral, useQueryState } from 'nuqs';
import { useParams, useSearchParams } from 'react-router';
import { useSessionStorage } from '@documenso/lib/client-only/hooks/use-session-storage';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { FolderType } from '@documenso/lib/types/folder-type';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
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 { EnvelopesBulkDeleteDialog } from '~/components/dialogs/envelopes-bulk-delete-dialog';
import { EnvelopesBulkMoveDialog } from '~/components/dialogs/envelopes-bulk-move-dialog';
@@ -22,12 +25,17 @@ import { TemplatesTable } from '~/components/tables/templates-table';
import { useCurrentTeam } from '~/providers/team';
import { appMetaTags } from '~/utils/meta';
const TEMPLATE_VIEWS = ['team', 'organisation'] as const;
type TemplateView = (typeof TEMPLATE_VIEWS)[number];
export function meta() {
return appMetaTags('Templates');
}
export default function TemplatesPage() {
const team = useCurrentTeam();
const organisation = useCurrentOrganisation();
const { folderId } = useParams();
const [searchParams] = useSearchParams();
@@ -35,6 +43,14 @@ export default function TemplatesPage() {
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 [rowSelection, setRowSelection] = useSessionStorage<RowSelectionState>(
'templates-bulk-selection',
{},
@@ -49,16 +65,41 @@ export default function TemplatesPage() {
const documentRootPath = formatDocumentsPath(team.url);
const templateRootPath = formatTemplatesPath(team.url);
const { data, isLoading, isLoadingError } = trpc.template.findTemplates.useQuery({
page: page,
perPage: perPage,
folderId,
});
const teamTemplatesQuery = trpc.template.findTemplates.useQuery(
{
page,
perPage,
folderId,
},
{
enabled: !isOrgView,
},
);
const orgTemplatesQuery = trpc.template.findOrganisationTemplates.useQuery(
{
page,
perPage,
},
{
enabled: isOrgView,
},
);
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">
<FolderGrid type={FolderType.TEMPLATE} parentId={folderId ?? null} />
{!isOrgView && <FolderGrid type={FolderType.TEMPLATE} parentId={folderId ?? null} />}
<div className="mt-8">
<div className="flex flex-row items-center">
@@ -74,8 +115,31 @@ 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>
</div>
)}
<div className="mt-8">
{data && data.count === 0 ? (
{activeQuery.data && activeQuery.data.count === 0 ? (
<div className="flex h-96 flex-col items-center justify-center gap-y-4 text-muted-foreground/60">
<Bird className="h-12 w-12" strokeWidth={1.5} />
@@ -85,51 +149,59 @@ export default function TemplatesPage() {
</h3>
<p className="mt-2 max-w-[50ch]">
<Trans>
You have not yet created any templates. To create a template please upload
one.
</Trans>
{isOrgView ? (
<Trans>No organisation templates are shared with your team yet.</Trans>
) : (
<Trans>
You have not yet created any templates. To create a template please upload
one.
</Trans>
)}
</p>
</div>
</div>
) : (
<TemplatesTable
data={data}
isLoading={isLoading}
isLoadingError={isLoadingError}
data={activeQuery.data}
isLoading={activeQuery.isLoading}
isLoadingError={activeQuery.isLoadingError}
documentRootPath={documentRootPath}
templateRootPath={templateRootPath}
enableSelection
rowSelection={rowSelection}
onRowSelectionChange={setRowSelection}
enableSelection={!isOrgView}
rowSelection={isOrgView ? {} : rowSelection}
onRowSelectionChange={isOrgView ? undefined : setRowSelection}
/>
)}
</div>
</div>
<EnvelopesTableBulkActionBar
selectedCount={selectedEnvelopeIds.length}
onMoveClick={() => setIsBulkMoveDialogOpen(true)}
onDeleteClick={() => setIsBulkDeleteDialogOpen(true)}
onClearSelection={() => setRowSelection({})}
/>
{!isOrgView && (
<>
<EnvelopesTableBulkActionBar
selectedCount={selectedEnvelopeIds.length}
onMoveClick={() => setIsBulkMoveDialogOpen(true)}
onDeleteClick={() => setIsBulkDeleteDialogOpen(true)}
onClearSelection={() => setRowSelection({})}
/>
<EnvelopesBulkMoveDialog
envelopeIds={selectedEnvelopeIds}
envelopeType={EnvelopeType.TEMPLATE}
open={isBulkMoveDialogOpen}
currentFolderId={folderId}
onOpenChange={setIsBulkMoveDialogOpen}
onSuccess={() => setRowSelection({})}
/>
<EnvelopesBulkMoveDialog
envelopeIds={selectedEnvelopeIds}
envelopeType={EnvelopeType.TEMPLATE}
open={isBulkMoveDialogOpen}
currentFolderId={folderId}
onOpenChange={setIsBulkMoveDialogOpen}
onSuccess={() => setRowSelection({})}
/>
<EnvelopesBulkDeleteDialog
envelopeIds={selectedEnvelopeIds}
envelopeType={EnvelopeType.TEMPLATE}
open={isBulkDeleteDialogOpen}
onOpenChange={setIsBulkDeleteDialogOpen}
onSuccess={() => setRowSelection({})}
/>
<EnvelopesBulkDeleteDialog
envelopeIds={selectedEnvelopeIds}
envelopeType={EnvelopeType.TEMPLATE}
open={isBulkDeleteDialogOpen}
onOpenChange={setIsBulkDeleteDialogOpen}
onSuccess={() => setRowSelection({})}
/>
</>
)}
</div>
</EnvelopeDropZoneWrapper>
);