Refactor third parties frontend to page arborescence

Mirror the risks refactor (c78a713): colocate routes.ts, split the
detail layout query so each child route owns its Loader + Page, rename
tabs/*Tab to resource folders with *Page, move dialogs into
_components/, and extract ThirdPartyRow with its own fragment.

Remove outlet context data passing and deprecated
loaderFromQueryLoader. Delete the monolithic ThirdPartyGraph hook,
colocating each GraphQL operation with its consumer: the create
mutation in CreateThirdPartyDialog (now useMutation + useToast) and
the third-party list queries in ThirdPartiesCell and
ThirdPartiesMultiSelectField (now useQueryLoader + usePreloadedQuery).

Signed-off-by: Sacha Al Himdani <sacha@probo.com>
This commit is contained in:
Sacha Al Himdani
2026-06-16 15:38:09 +00:00
parent 1df4af4556
commit a651d56c8c
51 changed files with 2851 additions and 1783 deletions

View File

@@ -15,16 +15,38 @@
import { faviconUrl } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import { Avatar, Badge, Button, Field, IconCrossLargeX, Option, Select } from "@probo/ui";
import { type ComponentProps, Suspense, useState } from "react";
import { type ComponentProps, Suspense, useEffect, useState } from "react";
import { type Control, Controller, type FieldValues, type Path } from "react-hook-form";
import { type PreloadedQuery, usePreloadedQuery, useQueryLoader } from "react-relay";
import { graphql } from "relay-runtime";
import { useThirdParties } from "#/hooks/graph/ThirdPartyGraph";
import type { ThirdPartiesMultiSelectFieldQuery } from "#/__generated__/core/ThirdPartiesMultiSelectFieldQuery.graphql";
const thirdPartiesQuery = graphql`
query ThirdPartiesMultiSelectFieldQuery($organizationId: ID!) {
organization: node(id: $organizationId) {
... on Organization {
thirdParties(
first: 100
orderBy: { direction: ASC, field: NAME }
) {
edges {
node {
id
name
websiteUrl
}
}
}
}
}
}
`;
type ThirdParty = {
id: string;
name: string;
websiteUrl: string | null | undefined;
level?: number;
};
type Props<T extends FieldValues = FieldValues> = {
@@ -42,29 +64,47 @@ export function ThirdPartiesMultiSelectField<T extends FieldValues = FieldValues
selectedThirdParties = [],
...props
}: Props<T>) {
const [queryRef, loadQuery]
= useQueryLoader<ThirdPartiesMultiSelectFieldQuery>(thirdPartiesQuery);
useEffect(() => {
loadQuery({ organizationId }, { fetchPolicy: "network-only" });
}, [loadQuery, organizationId]);
const loadingState = (
<Select variant="editor" disabled placeholder="Loading..." />
);
return (
<Field {...props}>
<Suspense
fallback={<Select variant="editor" disabled placeholder="Loading..." />}
>
<ThirdPartiesMultiSelectWithQuery
organizationId={organizationId}
control={control}
name={props.name}
disabled={props.disabled}
selectedThirdParties={selectedThirdParties}
/>
</Suspense>
{queryRef
? (
<Suspense fallback={loadingState}>
<ThirdPartiesMultiSelectWithQuery
queryRef={queryRef}
control={control}
name={props.name}
disabled={props.disabled}
selectedThirdParties={selectedThirdParties}
/>
</Suspense>
)
: (
loadingState
)}
</Field>
);
}
function ThirdPartiesMultiSelectWithQuery<T extends FieldValues = FieldValues>(
props: Pick<Props<T>, "organizationId" | "control" | "name" | "disabled" | "selectedThirdParties">,
props: Pick<Props<T>, "control" | "name" | "disabled" | "selectedThirdParties"> & {
queryRef: PreloadedQuery<ThirdPartiesMultiSelectFieldQuery>;
},
) {
const { __ } = useTranslate();
const { name, organizationId, control, selectedThirdParties = [] } = props;
const thirdParties = useThirdParties(organizationId);
const { name, control, selectedThirdParties = [] } = props;
const data = usePreloadedQuery<ThirdPartiesMultiSelectFieldQuery>(thirdPartiesQuery, props.queryRef);
const thirdParties = data.organization?.thirdParties?.edges.map(edge => edge.node) ?? [];
const [isOpen, setIsOpen] = useState(false);
const allThirdParties: ThirdParty[] = [...thirdParties];

View File

@@ -14,10 +14,31 @@
import { faviconUrl } from "@probo/helpers";
import { Avatar, Badge, IconCrossLargeX } from "@probo/ui";
import { graphql } from "relay-runtime";
import type { ThirdPartyGraphSelectQuery } from "#/__generated__/core/ThirdPartyGraphSelectQuery.graphql";
import type { ThirdPartiesCellQuery } from "#/__generated__/core/ThirdPartiesCellQuery.graphql";
import { GraphQLCell } from "#/components/table/GraphQLCell";
import { thirdPartiesSelectQuery } from "#/hooks/graph/ThirdPartyGraph";
const thirdPartiesCellQuery = graphql`
query ThirdPartiesCellQuery($organizationId: ID!) {
organization: node(id: $organizationId) {
... on Organization {
thirdParties(
first: 100
orderBy: { direction: ASC, field: NAME }
) {
edges {
node {
id
name
websiteUrl
}
}
}
}
}
}
`;
type ThirdParty = {
id: string;
@@ -35,15 +56,19 @@ const empty = [] as ThirdParty[];
export function ThirdPartiesCell(props: Props) {
return (
<GraphQLCell<ThirdPartyGraphSelectQuery, ThirdParty>
<GraphQLCell<ThirdPartiesCellQuery, ThirdParty>
multiple
name={props.name}
query={thirdPartiesSelectQuery}
query={thirdPartiesCellQuery}
variables={{
organizationId: props.organizationId,
}}
items={data =>
data.organization?.thirdParties?.edges?.map(edge => edge.node) ?? []}
data.organization?.thirdParties?.edges?.map(edge => ({
id: edge.node.id,
name: edge.node.name,
websiteUrl: edge.node.websiteUrl,
})) ?? []}
itemRenderer={({ item, onRemove }) => (
<ThirdPartyBadge thirdParty={item} onRemove={onRemove} />
)}