setSearchQuery(e.target.value)}
+ value={searchQuery}
+ onChange={(e) => void setSearchQuery(e.target.value || null)}
placeholder={_(msg`Search`)}
/>
diff --git a/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.teams.tsx b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.teams.tsx
index e2408b4a8..8f54555b7 100644
--- a/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.teams.tsx
+++ b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.teams.tsx
@@ -1,8 +1,6 @@
-import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { Input } from '@documenso/ui/primitives/input';
import { useLingui } from '@lingui/react/macro';
-import { useEffect, useState } from 'react';
-import { useLocation, useSearchParams } from 'react-router';
+import { debounce, parseAsString, useQueryState } from 'nuqs';
import { TeamCreateDialog } from '~/components/dialogs/team-create-dialog';
import { SettingsHeader } from '~/components/general/settings-header';
@@ -11,27 +9,10 @@ import { OrganisationTeamsTable } from '~/components/tables/organisation-teams-t
export default function OrganisationSettingsTeamsPage() {
const { t } = useLingui();
- const [searchParams, setSearchParams] = useSearchParams();
- const { pathname } = useLocation();
-
- const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
-
- const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
-
- /**
- * Handle debouncing the search query.
- */
- useEffect(() => {
- const params = new URLSearchParams(searchParams?.toString());
-
- params.set('query', debouncedSearchQuery);
-
- if (debouncedSearchQuery === '') {
- params.delete('query');
- }
-
- setSearchParams(params);
- }, [debouncedSearchQuery, pathname, searchParams]);
+ const [searchQuery, setSearchQuery] = useQueryState(
+ 'query',
+ parseAsString.withDefault('').withOptions({ shallow: false, limitUrlUpdates: debounce(500) }),
+ );
return (
@@ -40,8 +21,8 @@ export default function OrganisationSettingsTeamsPage() {
setSearchQuery(e.target.value)}
+ value={searchQuery}
+ onChange={(e) => void setSearchQuery(e.target.value || null)}
placeholder={t`Search`}
className="mb-4"
/>
diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.groups.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.groups.tsx
index cae095e1b..e02eda14e 100644
--- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.groups.tsx
+++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.groups.tsx
@@ -1,11 +1,9 @@
-import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { trpc } from '@documenso/trpc/react';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
import { Input } from '@documenso/ui/primitives/input';
import { useLingui } from '@lingui/react/macro';
import { OrganisationGroupType, OrganisationMemberRole } from '@prisma/client';
-import { useEffect, useState } from 'react';
-import { useLocation, useSearchParams } from 'react-router';
+import { debounce, parseAsString, useQueryState } from 'nuqs';
import { TeamGroupCreateDialog } from '~/components/dialogs/team-group-create-dialog';
import { SettingsHeader } from '~/components/general/settings-header';
@@ -16,34 +14,12 @@ import { useCurrentTeam } from '~/providers/team';
export default function TeamsSettingsGroupsPage() {
const { t } = useLingui();
- const [searchParams, setSearchParams] = useSearchParams();
-
- const { pathname } = useLocation();
const team = useCurrentTeam();
- const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
-
- const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
-
- /**
- * Handle debouncing the search query.
- */
- useEffect(() => {
- const params = new URLSearchParams(searchParams?.toString());
-
- params.set('query', debouncedSearchQuery);
-
- if (debouncedSearchQuery === '') {
- params.delete('query');
- }
-
- // If nothing to change then do nothing.
- if (params.toString() === searchParams?.toString()) {
- return;
- }
-
- setSearchParams(params);
- }, [debouncedSearchQuery, pathname, searchParams]);
+ const [searchQuery, setSearchQuery] = useQueryState(
+ 'query',
+ parseAsString.withDefault('').withOptions({ shallow: false, limitUrlUpdates: debounce(500) }),
+ );
const everyoneGroupQuery = trpc.team.group.find.useQuery({
teamId: team.id,
@@ -61,8 +37,8 @@ export default function TeamsSettingsGroupsPage() {
setSearchQuery(e.target.value)}
+ value={searchQuery}
+ onChange={(e) => void setSearchQuery(e.target.value || null)}
placeholder={t`Search`}
className="mb-4"
/>
diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.members.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.members.tsx
index 6a2da2d53..12a2b3a6f 100644
--- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.members.tsx
+++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.members.tsx
@@ -1,8 +1,6 @@
-import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { Input } from '@documenso/ui/primitives/input';
import { useLingui } from '@lingui/react/macro';
-import { useEffect, useState } from 'react';
-import { useLocation, useSearchParams } from 'react-router';
+import { debounce, parseAsString, useQueryState } from 'nuqs';
import { TeamMemberCreateDialog } from '~/components/dialogs/team-member-create-dialog';
import { SettingsHeader } from '~/components/general/settings-header';
@@ -11,32 +9,10 @@ import { TeamMembersTable } from '~/components/tables/team-members-table';
export default function TeamsSettingsMembersPage() {
const { t } = useLingui();
- const [searchParams, setSearchParams] = useSearchParams();
- const { pathname } = useLocation();
-
- const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
-
- const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
-
- /**
- * Handle debouncing the search query.
- */
- useEffect(() => {
- const params = new URLSearchParams(searchParams?.toString());
-
- params.set('query', debouncedSearchQuery);
-
- if (debouncedSearchQuery === '') {
- params.delete('query');
- }
-
- // If nothing to change then do nothing.
- if (params.toString() === searchParams?.toString()) {
- return;
- }
-
- setSearchParams(params);
- }, [debouncedSearchQuery, pathname, searchParams]);
+ const [searchQuery, setSearchQuery] = useQueryState(
+ 'query',
+ parseAsString.withDefault('').withOptions({ shallow: false, limitUrlUpdates: debounce(500) }),
+ );
return (
@@ -45,8 +21,8 @@ export default function TeamsSettingsMembersPage() {
setSearchQuery(e.target.value)}
+ value={searchQuery}
+ onChange={(e) => void setSearchQuery(e.target.value || null)}
placeholder={t`Search`}
className="mb-4"
/>
diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks.$id._index.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks.$id._index.tsx
index faff66cf6..d334df802 100644
--- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks.$id._index.tsx
+++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks.$id._index.tsx
@@ -1,4 +1,3 @@
-import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params';
@@ -20,7 +19,8 @@ import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { WebhookCallStatus, WebhookTriggerEvents } from '@prisma/client';
import { CheckCircle2Icon, ChevronRightIcon, PencilIcon, TerminalIcon, XCircleIcon } from 'lucide-react';
-import { useEffect, useMemo, useState } from 'react';
+import { debounce, parseAsString, useQueryState } from 'nuqs';
+import { useMemo } from 'react';
import { Link, useLocation, useNavigate, useSearchParams } from 'react-router';
import { z } from 'zod';
@@ -51,13 +51,14 @@ export default function WebhookPage({ params }: Route.ComponentProps) {
const { toast } = useToast();
const { pathname } = useLocation();
- const [searchParams, setSearchParams] = useSearchParams();
+ const [searchParams] = useSearchParams();
const updateSearchParams = useUpdateSearchParams();
const team = useCurrentTeam();
- const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
-
- const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
+ const [searchQuery, setSearchQuery] = useQueryState(
+ 'query',
+ parseAsString.withDefault('').withOptions({ shallow: false, limitUrlUpdates: debounce(500) }),
+ );
const parsedSearchParams = WebhookSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
@@ -81,26 +82,6 @@ export default function WebhookPage({ params }: Route.ComponentProps) {
query: parsedSearchParams.query,
});
- /**
- * Handle debouncing the search query.
- */
- useEffect(() => {
- const params = new URLSearchParams(searchParams?.toString());
-
- params.set('query', debouncedSearchQuery);
-
- if (debouncedSearchQuery === '') {
- params.delete('query');
- }
-
- // If nothing to change then do nothing.
- if (params.toString() === searchParams?.toString()) {
- return;
- }
-
- setSearchParams(params);
- }, [debouncedSearchQuery, pathname, searchParams]);
-
const onPaginationChange = (page: number, perPage: number) => {
updateSearchParams({
page,
@@ -252,8 +233,8 @@ export default function WebhookPage({ params }: Route.ComponentProps) {
setSearchQuery(e.target.value)}
+ value={searchQuery}
+ onChange={(e) => void setSearchQuery(e.target.value || null)}
placeholder={t`Search by ID`}
/>
diff --git a/packages/email/template-components/template-access-auth-2fa.tsx b/packages/email/template-components/template-access-auth-2fa.tsx
index 7873a1735..a7918ad4b 100644
--- a/packages/email/template-components/template-access-auth-2fa.tsx
+++ b/packages/email/template-components/template-access-auth-2fa.tsx
@@ -1,6 +1,7 @@
import { Plural, Trans } from '@lingui/react/macro';
import { Heading, Img, Section, Text } from '../components';
+import { getEmailAssetUrl } from '../utils/asset-url';
export type TemplateAccessAuth2FAProps = {
documentTitle: string;
@@ -18,13 +19,9 @@ export const TemplateAccessAuth2FA = ({
expiresInMinutes,
assetBaseUrl = 'http://localhost:3002',
}: TemplateAccessAuth2FAProps) => {
- const getAssetUrl = (path: string) => {
- return new URL(path, assetBaseUrl).toString();
- };
-
return (
-
})
+
diff --git a/packages/email/template-components/template-branding-logo.tsx b/packages/email/template-components/template-branding-logo.tsx
index c7e99684e..6b560aed2 100644
--- a/packages/email/template-components/template-branding-logo.tsx
+++ b/packages/email/template-components/template-branding-logo.tsx
@@ -1,5 +1,6 @@
import { Img, Link } from '../components';
import { useBranding } from '../providers/branding';
+import { getEmailAssetUrl } from '../utils/asset-url';
import { getSafeBrandingUrl } from '../utils/branding-url';
export type TemplateBrandingLogoProps = {
@@ -20,7 +21,7 @@ export const TemplateBrandingLogo = ({ assetBaseUrl, className = 'mb-4 h-6' }: T
const hasCustomBrandingLogo = branding.brandingEnabled && Boolean(branding.brandingLogo);
if (!hasCustomBrandingLogo) {
- const documensoLogoUrl = new URL('/static/logo.png', assetBaseUrl).toString();
+ const documensoLogoUrl = getEmailAssetUrl(assetBaseUrl, 'static/logo.png');
return
;
}
diff --git a/packages/email/template-components/template-document-completed.tsx b/packages/email/template-components/template-document-completed.tsx
index ec453f315..20b7000e1 100644
--- a/packages/email/template-components/template-document-completed.tsx
+++ b/packages/email/template-components/template-document-completed.tsx
@@ -1,6 +1,7 @@
import { Trans } from '@lingui/react/macro';
import { Button, Column, Img, Section, Text } from '../components';
+import { getEmailAssetUrl } from '../utils/asset-url';
import { TemplateDocumentImage } from './template-document-image';
export interface TemplateDocumentCompletedProps {
@@ -16,10 +17,6 @@ export const TemplateDocumentCompleted = ({
assetBaseUrl,
customBody,
}: TemplateDocumentCompletedProps) => {
- const getAssetUrl = (path: string) => {
- return new URL(path, assetBaseUrl).toString();
- };
-
return (
<>
@@ -29,7 +26,7 @@ export const TemplateDocumentCompleted = ({
@@ -51,7 +48,11 @@ export const TemplateDocumentCompleted = ({
className="rounded-lg border border-border border-solid px-4 py-2 text-center font-medium text-foreground text-sm no-underline"
href={downloadLink}
>
-
+
Download
diff --git a/packages/email/template-components/template-document-image.tsx b/packages/email/template-components/template-document-image.tsx
index ed45512a5..88c06282c 100644
--- a/packages/email/template-components/template-document-image.tsx
+++ b/packages/email/template-components/template-document-image.tsx
@@ -1,4 +1,5 @@
import { Column, Img, Row, Section } from '../components';
+import { getEmailAssetUrl } from '../utils/asset-url';
export interface TemplateDocumentImageProps {
assetBaseUrl: string;
@@ -6,17 +7,13 @@ export interface TemplateDocumentImageProps {
}
export const TemplateDocumentImage = ({ assetBaseUrl, className }: TemplateDocumentImageProps) => {
- const getAssetUrl = (path: string) => {
- return new URL(path, assetBaseUrl).toString();
- };
-
return (
-
+
diff --git a/packages/email/template-components/template-document-pending.tsx b/packages/email/template-components/template-document-pending.tsx
index c44ae5c81..5cc435e28 100644
--- a/packages/email/template-components/template-document-pending.tsx
+++ b/packages/email/template-components/template-document-pending.tsx
@@ -1,6 +1,7 @@
import { Trans } from '@lingui/react/macro';
import { Column, Img, Section, Text } from '../components';
+import { getEmailAssetUrl } from '../utils/asset-url';
import { TemplateDocumentImage } from './template-document-image';
export interface TemplateDocumentPendingProps {
@@ -9,10 +10,6 @@ export interface TemplateDocumentPendingProps {
}
export const TemplateDocumentPending = ({ documentName, assetBaseUrl }: TemplateDocumentPendingProps) => {
- const getAssetUrl = (path: string) => {
- return new URL(path, assetBaseUrl).toString();
- };
-
return (
<>
@@ -21,7 +18,11 @@ export const TemplateDocumentPending = ({ documentName, assetBaseUrl }: Template
-
+
Waiting for others
diff --git a/packages/email/template-components/template-document-recipient-signed.tsx b/packages/email/template-components/template-document-recipient-signed.tsx
index 42ebe0e10..9077091fb 100644
--- a/packages/email/template-components/template-document-recipient-signed.tsx
+++ b/packages/email/template-components/template-document-recipient-signed.tsx
@@ -1,6 +1,7 @@
import { Trans } from '@lingui/react/macro';
import { Column, Img, Section, Text } from '../components';
+import { getEmailAssetUrl } from '../utils/asset-url';
import { TemplateDocumentImage } from './template-document-image';
export interface TemplateDocumentRecipientSignedProps {
@@ -16,10 +17,6 @@ export const TemplateDocumentRecipientSigned = ({
recipientEmail,
assetBaseUrl,
}: TemplateDocumentRecipientSignedProps) => {
- const getAssetUrl = (path: string) => {
- return new URL(path, assetBaseUrl).toString();
- };
-
const recipientReference = recipientName || recipientEmail;
return (
@@ -31,7 +28,7 @@ export const TemplateDocumentRecipientSigned = ({
diff --git a/packages/email/template-components/template-document-self-signed.tsx b/packages/email/template-components/template-document-self-signed.tsx
index 852843577..729a3b156 100644
--- a/packages/email/template-components/template-document-self-signed.tsx
+++ b/packages/email/template-components/template-document-self-signed.tsx
@@ -2,6 +2,7 @@ import { env } from '@documenso/lib/utils/env';
import { Trans } from '@lingui/react/macro';
import { Button, Column, Img, Link, Section, Text } from '../components';
+import { getEmailAssetUrl } from '../utils/asset-url';
import { TemplateDocumentImage } from './template-document-image';
export interface TemplateDocumentSelfSignedProps {
@@ -14,10 +15,6 @@ export const TemplateDocumentSelfSigned = ({ documentName, assetBaseUrl }: Templ
const signUpUrl = `${NEXT_PUBLIC_WEBAPP_URL ?? 'http://localhost:3000'}/signup`;
- const getAssetUrl = (path: string) => {
- return new URL(path, assetBaseUrl).toString();
- };
-
return (
<>
@@ -27,7 +24,7 @@ export const TemplateDocumentSelfSigned = ({ documentName, assetBaseUrl }: Templ
@@ -56,7 +53,7 @@ export const TemplateDocumentSelfSigned = ({ documentName, assetBaseUrl }: Templ
className="mr-4 rounded-lg border border-border border-solid px-4 py-2 text-center font-medium text-foreground text-sm no-underline"
>
@@ -67,7 +64,11 @@ export const TemplateDocumentSelfSigned = ({ documentName, assetBaseUrl }: Templ
className="rounded-lg border border-border border-solid px-4 py-2 text-center font-medium text-foreground text-sm no-underline"
href="https://documenso.com/pricing"
>
-
+
View plans
diff --git a/packages/email/template-components/template-image.tsx b/packages/email/template-components/template-image.tsx
index b16bcfe60..198c55aec 100644
--- a/packages/email/template-components/template-image.tsx
+++ b/packages/email/template-components/template-image.tsx
@@ -1,4 +1,5 @@
import { Img } from '../components';
+import { getEmailAssetUrl } from '../utils/asset-url';
export interface TemplateImageProps {
assetBaseUrl: string;
@@ -7,11 +8,7 @@ export interface TemplateImageProps {
}
export const TemplateImage = ({ assetBaseUrl, className, staticAsset }: TemplateImageProps) => {
- const getAssetUrl = (path: string) => {
- return new URL(path, assetBaseUrl).toString();
- };
-
- return
;
+ return
;
};
export default TemplateImage;
diff --git a/packages/email/templates/admin-user-created.tsx b/packages/email/templates/admin-user-created.tsx
index ccc92020b..000b4d3cb 100644
--- a/packages/email/templates/admin-user-created.tsx
+++ b/packages/email/templates/admin-user-created.tsx
@@ -5,6 +5,7 @@ import { Body, Container, Head, Html, Img, Preview, Section } from '../component
import type { TemplateAdminUserCreatedProps } from '../template-components/template-admin-user-created';
import { TemplateAdminUserCreated } from '../template-components/template-admin-user-created';
import { TemplateFooter } from '../template-components/template-footer';
+import { getEmailAssetUrl } from '../utils/asset-url';
export const AdminUserCreatedTemplate = ({
resetPasswordLink,
@@ -14,10 +15,6 @@ export const AdminUserCreatedTemplate = ({
const previewText = msg`Set your password for Documenso`;
- const getAssetUrl = (path: string) => {
- return new URL(path, assetBaseUrl).toString();
- };
-
return (
@@ -27,7 +24,7 @@ export const AdminUserCreatedTemplate = ({
-
+
diff --git a/packages/email/utils/asset-url.ts b/packages/email/utils/asset-url.ts
new file mode 100644
index 000000000..f6143de19
--- /dev/null
+++ b/packages/email/utils/asset-url.ts
@@ -0,0 +1,17 @@
+/**
+ * Resolve a static email asset path against the asset base URL.
+ *
+ * The base is normalised to end with a trailing slash and the path is
+ * normalised to have no leading slash, so a sub-path in the base URL
+ * (e.g. "/ESign") is preserved. Passing a root-absolute path straight to
+ * `new URL()` would otherwise replace the base pathname entirely.
+ *
+ * `getEmailAssetUrl('https://host/ESign', 'static/logo.png')` -> `https://host/ESign/static/logo.png`
+ * `getEmailAssetUrl('https://host/ESign/', '/static/logo.png')` -> `https://host/ESign/static/logo.png`
+ */
+export const getEmailAssetUrl = (assetBaseUrl: string, path: string): string => {
+ const base = assetBaseUrl.endsWith('/') ? assetBaseUrl : `${assetBaseUrl}/`;
+ const relativePath = path.startsWith('/') ? path.slice(1) : path;
+
+ return new URL(relativePath, base).toString();
+};
diff --git a/packages/trpc/server/team-router/find-teams.ts b/packages/trpc/server/team-router/find-teams.ts
index e4466d96c..825637687 100644
--- a/packages/trpc/server/team-router/find-teams.ts
+++ b/packages/trpc/server/team-router/find-teams.ts
@@ -8,7 +8,7 @@ export const findTeamsRoute = authenticatedProcedure
.input(ZFindTeamsRequestSchema)
.output(ZFindTeamsResponseSchema)
.query(async ({ ctx, input }) => {
- const { organisationId } = input;
+ const { organisationId, query, page, perPage } = input;
const { user } = ctx;
ctx.logger.info({
@@ -17,5 +17,5 @@ export const findTeamsRoute = authenticatedProcedure
},
});
- return findTeams({ userId: user.id, organisationId });
+ return findTeams({ userId: user.id, organisationId, query, page, perPage });
});