Paginate admin panel app registrations list (#22734)

## Context

The `findAllApplicationRegistrations` query on the admin panel Apps page
(`/settings/admin-panel#apps`) loaded every application registration at
once, with search and filtering done client-side.

## Changes

**Server**
- `findAllApplicationRegistrations` now takes `limit` / `offset` /
`searchTerm` / `isPreInstalledOnly` args and returns a
`PaginatedApplicationRegistrations` object (`registrations`,
`totalCount`, `hasMore`), following the same pattern as `getQueueJobs`.
- `ApplicationRegistrationService.findAll` uses `findAndCount` with
`take`/`skip`, and moves the search (name, source package, universal
identifier via `ILIKE`) and the pre-installed filter into the SQL query,
mirroring how `getInstalledWorkspacesGlobal` filters installed
workspaces.

**Frontend**
- `SettingsAdminApps` passes the page, the debounced search term (300ms,
like the installed workspaces table), and the pre-installed toggle as
query variables instead of filtering client-side.
- Adds a Previous / Next pagination footer (25 per page) matching the
queue jobs table, shown only when there is more than one page.
- The "unconfigured first" ordering is kept within each page
(`isConfigured` is a dataloader-resolved field, so it can't be sorted in
SQL).

## Notes
- Regenerated `generated-admin/graphql.ts` follows in a subsequent
commit.

---
_Generated by [Claude
Code](https://claude.ai/code/session_015erumgPozkbNA3zPeKrrFW)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22734?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->

---------

Co-authored-by: Weiko <corentin@twenty.com>
This commit is contained in:
martmull
2026-07-13 14:07:11 +02:00
committed by GitHub
parent b06f3c7f8f
commit 7381038452
12 changed files with 353 additions and 98 deletions
@@ -1,5 +1,6 @@
import { ApplicationDisplay } from '@/applications/components/ApplicationDisplay';
import { useApolloAdminClient } from '@/settings/admin-panel/apollo/hooks/useApolloAdminClient';
import { SettingsEmptyPlaceholder } from '@/settings/components/SettingsEmptyPlaceholder';
import { StyledNameTableCell } from '@/settings/data-model/object-details/components/SettingsObjectItemTableRowStyledComponents';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
@@ -14,9 +15,19 @@ import { useMutation, useQuery } from '@apollo/client/react';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { type ReactNode, useContext, useState } from 'react';
import { assertUnreachable, getSettingsPath } from 'twenty-shared/utils';
import { useDebounce } from 'use-debounce';
import {
assertUnreachable,
getSettingsPath,
isDefined,
} from 'twenty-shared/utils';
import { SettingsPath } from 'twenty-shared/types';
import { IconChevronRight, IconPinned, IconRefresh } from 'twenty-ui/icon';
import {
IconChevronRight,
IconDotsVertical,
IconPinned,
IconRefresh,
} from 'twenty-ui/icon';
import { H2Title } from 'twenty-ui/typography';
import { Button, SearchInput } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
@@ -35,18 +46,36 @@ const StyledTableContainer = styled.div`
margin-top: ${themeCssVariables.spacing[3]};
`;
const StyledShowMoreContainer = styled.div`
display: flex;
justify-content: center;
margin-top: ${themeCssVariables.spacing[2]};
`;
const TABLE_GRID = '1fr 100px 100px 100px 40px';
const TABLE_GRID_MOBILE = '3fr 3fr 1fr 1fr 40px';
const PAGE_SIZE = 25;
export const SettingsAdminApps = () => {
const apolloAdminClient = useApolloAdminClient();
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
const [searchQuery, setSearchQuery] = useState('');
const [debouncedSearchQuery] = useDebounce(searchQuery, 300);
const [showPreInstalledOnly, setShowPreInstalledOnly] = useState(false);
const { data } = useQuery(FindAllApplicationRegistrationsDocument, {
client: apolloAdminClient,
});
const { data, loading, fetchMore } = useQuery(
FindAllApplicationRegistrationsDocument,
{
client: apolloAdminClient,
notifyOnNetworkStatusChange: true,
variables: {
limit: PAGE_SIZE,
offset: 0,
searchTerm: debouncedSearchQuery,
isPreInstalledOnly: showPreInstalledOnly,
},
},
);
const [syncMarketplaceCatalog, { loading: isSyncing }] = useMutation(
SyncMarketplaceCatalogDocument,
@@ -66,27 +95,35 @@ export const SettingsAdminApps = () => {
}
};
const registrations = data?.findAllApplicationRegistrations ?? [];
const registrations = [
...(data?.findAllApplicationRegistrations.registrations ?? []),
].sort((a, b) => Number(a.isConfigured) - Number(b.isConfigured));
const query = searchQuery.trim().toLowerCase();
const hasMore = data?.findAllApplicationRegistrations.hasMore ?? false;
const filtered = registrations
.filter((registration) => {
if (showPreInstalledOnly && !registration.isPreInstalled) {
return false;
}
const handleShowMore = () => {
fetchMore({
variables: {
limit: PAGE_SIZE,
offset: registrations.length,
},
updateQuery: (previousData, { fetchMoreResult }) => {
if (!isDefined(fetchMoreResult)) {
return previousData;
}
if (query.length === 0) {
return true;
}
return (
registration.name.toLowerCase().includes(query) ||
(registration.sourcePackage ?? '').toLowerCase().includes(query) ||
registration.universalIdentifier.toLowerCase().includes(query)
);
})
.toSorted((a, b) => Number(a.isConfigured) - Number(b.isConfigured));
return {
findAllApplicationRegistrations: {
...fetchMoreResult.findAllApplicationRegistrations,
registrations: [
...previousData.findAllApplicationRegistrations.registrations,
...fetchMoreResult.findAllApplicationRegistrations.registrations,
],
},
};
},
});
};
const getFormattedSource = (
registration: ApplicationRegistrationFragmentFragment,
@@ -159,29 +196,47 @@ export const SettingsAdminApps = () => {
/>
)}
/>
<StyledTableContainer>
<Table>
<TableRow
gridAutoColumns={TABLE_GRID}
mobileGridAutoColumns={TABLE_GRID_MOBILE}
>
<TableHeader>{t`Name`}</TableHeader>
<TableHeader align="right">{t`Source`}</TableHeader>
<TableHeader align="right">{t`Listed`}</TableHeader>
<TableHeader align="right">{t`Configured`}</TableHeader>
<TableHeader></TableHeader>
</TableRow>
<TableBody>
{filtered.map((registration) => (
<SettingsAdminAppsTableRow
key={registration.id}
registration={registration}
getFormattedSource={getFormattedSource}
/>
))}
</TableBody>
</Table>
</StyledTableContainer>
{loading && registrations.length === 0 ? (
<SettingsEmptyPlaceholder>{t`Loading apps...`}</SettingsEmptyPlaceholder>
) : registrations.length === 0 ? (
<SettingsEmptyPlaceholder>{t`No apps found`}</SettingsEmptyPlaceholder>
) : (
<StyledTableContainer>
<Table>
<TableRow
gridAutoColumns={TABLE_GRID}
mobileGridAutoColumns={TABLE_GRID_MOBILE}
>
<TableHeader>{t`Name`}</TableHeader>
<TableHeader align="right">{t`Source`}</TableHeader>
<TableHeader align="right">{t`Listed`}</TableHeader>
<TableHeader align="right">{t`Configured`}</TableHeader>
<TableHeader></TableHeader>
</TableRow>
<TableBody>
{registrations.map((registration) => (
<SettingsAdminAppsTableRow
key={registration.id}
registration={registration}
getFormattedSource={getFormattedSource}
/>
))}
</TableBody>
</Table>
</StyledTableContainer>
)}
{hasMore && (
<StyledShowMoreContainer>
<Button
title={t`Show more`}
Icon={IconDotsVertical}
onClick={handleShowMore}
disabled={loading}
size="small"
variant="secondary"
/>
</StyledShowMoreContainer>
)}
</Section>
</>
);
@@ -3,9 +3,22 @@ import { gql } from '@apollo/client';
import { APPLICATION_REGISTRATION_FRAGMENT } from '@/settings/application-registrations/graphql/fragments/applicationRegistrationFragment';
export const FIND_ALL_APPLICATION_REGISTRATIONS = gql`
query FindAllApplicationRegistrations {
findAllApplicationRegistrations {
...ApplicationRegistrationFragment
query FindAllApplicationRegistrations(
$limit: Int
$offset: Int
$searchTerm: String
$isPreInstalledOnly: Boolean
) {
findAllApplicationRegistrations(
limit: $limit
offset: $offset
searchTerm: $searchTerm
isPreInstalledOnly: $isPreInstalledOnly
) {
registrations {
...ApplicationRegistrationFragment
}
hasMore
}
}
${APPLICATION_REGISTRATION_FRAGMENT}