feat: add link to relation filtered table in Record Show Page (#3261)

* feat: add link to relation filtered table in Record Show Page

Closes #3125

* refactor: use generateFindManyRecordsQuery for optimization

* Fixes from review

* Minor fixes

---------

Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com>
This commit is contained in:
Thaïs 2024-01-11 16:51:06 -03:00 committed by GitHub
parent b3d9bed91d
commit 985c2f321e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 960 additions and 122 deletions

View File

@ -117,6 +117,7 @@
"pg-boss": "^9.0.3",
"prettier": "^3.0.3",
"prism-react-renderer": "^2.1.0",
"qs": "^6.11.2",
"react": "^18.2.0",
"react-data-grid": "7.0.0-beta.13",
"react-datepicker": "^4.11.0",

View File

@ -83,12 +83,14 @@ export const useObjectMetadataItem = (
objectMetadataItem,
});
const findManyRecordsQuery = useGenerateFindManyRecordsQuery({
const generateFindManyRecordsQuery = useGenerateFindManyRecordsQuery();
const findManyRecordsQuery = generateFindManyRecordsQuery({
objectMetadataItem,
depth,
});
const findOneRecordQuery = useGenerateFindOneRecordQuery({
const generateFindOneRecordQuery = useGenerateFindOneRecordQuery();
const findOneRecordQuery = generateFindOneRecordQuery({
objectMetadataItem,
depth,
});

View File

@ -38,7 +38,7 @@ export const formatFieldMetadataItemsAsFilterDefinitions = ({
return [...acc, formatFieldMetadataItemAsFilterDefinition({ field })];
}, [] as FilterDefinition[]);
const formatFieldMetadataItemAsFilterDefinition = ({
export const formatFieldMetadataItemAsFilterDefinition = ({
field,
}: {
field: ObjectMetadataItem['fields'][0];

View File

@ -4,16 +4,16 @@ import { useMapFieldMetadataToGraphQLQuery } from '@/object-metadata/hooks/useMa
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { capitalize } from '~/utils/string/capitalize';
export const useGenerateFindManyRecordsQuery = ({
objectMetadataItem,
depth,
}: {
objectMetadataItem: ObjectMetadataItem;
depth?: number;
}) => {
export const useGenerateFindManyRecordsQuery = () => {
const mapFieldMetadataToGraphQLQuery = useMapFieldMetadataToGraphQLQuery();
return gql`
return ({
objectMetadataItem,
depth,
}: {
objectMetadataItem: ObjectMetadataItem;
depth?: number;
}) => gql`
query FindMany${capitalize(
objectMetadataItem.namePlural,
)}($filter: ${capitalize(

View File

@ -1,34 +1,30 @@
import { gql } from '@apollo/client';
import { useMapFieldMetadataToGraphQLQuery } from '@/object-metadata/hooks/useMapFieldMetadataToGraphQLQuery';
import { EMPTY_QUERY } from '@/object-metadata/hooks/useObjectMetadataItem';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
export const useGenerateFindOneRecordQuery = ({
objectMetadataItem,
depth,
}: {
objectMetadataItem: ObjectMetadataItem;
depth?: number;
}) => {
export const useGenerateFindOneRecordQuery = () => {
const mapFieldMetadataToGraphQLQuery = useMapFieldMetadataToGraphQLQuery();
if (!objectMetadataItem) {
return EMPTY_QUERY;
}
return gql`
query FindOne${objectMetadataItem.nameSingular}($objectRecordId: UUID!) {
${objectMetadataItem.nameSingular}(filter: {
id: {
eq: $objectRecordId
return ({
objectMetadataItem,
depth,
}: {
objectMetadataItem: Pick<ObjectMetadataItem, 'nameSingular' | 'fields'>;
depth?: number;
}) =>
gql`
query FindOne${objectMetadataItem.nameSingular}($objectRecordId: UUID!) {
${objectMetadataItem.nameSingular}(filter: {
id: {
eq: $objectRecordId
}
}){
id
${objectMetadataItem.fields
.map((field) => mapFieldMetadataToGraphQLQuery(field, depth))
.join('\n')}
}
}){
id
${objectMetadataItem.fields
.map((field) => mapFieldMetadataToGraphQLQuery(field, depth))
.join('\n')}
}
}
`;
`;
};

View File

@ -1,9 +1,12 @@
import { useCallback, useContext, useEffect, useMemo } from 'react';
import { Link } from 'react-router-dom';
import { css } from '@emotion/react';
import styled from '@emotion/styled';
import qs from 'qs';
import { useRecoilValue } from 'recoil';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { parseFieldRelationType } from '@/object-metadata/utils/parseFieldRelationType';
import { FieldContext } from '@/object-record/field/contexts/FieldContext';
import { usePersistField } from '@/object-record/field/hooks/usePersistField';
import { entityFieldsFamilyState } from '@/object-record/field/states/entityFieldsFamilyState';
@ -23,7 +26,10 @@ import { LightIconButton } from '@/ui/input/button/components/LightIconButton';
import { Card } from '@/ui/layout/card/components/Card';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { DropdownScope } from '@/ui/layout/dropdown/scopes/DropdownScope';
import { Section } from '@/ui/layout/section/components/Section';
import { FilterQueryParams } from '@/views/hooks/internal/useFiltersFromQueryParams';
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
const StyledAddDropdown = styled(Dropdown)`
margin-left: auto;
@ -54,8 +60,23 @@ const StyledHeader = styled.header<{ isDropdownOpen?: boolean }>`
`;
const StyledTitle = styled.div`
align-items: flex-end;
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
`;
const StyledTitleLabel = styled.div`
font-weight: ${({ theme }) => theme.font.weight.medium};
padding: ${({ theme }) => theme.spacing(0, 1)};
`;
const StyledLink = styled(Link)`
color: ${({ theme }) => theme.font.color.light};
text-decoration: none;
font-size: ${({ theme }) => theme.font.size.sm};
:hover {
color: ${({ theme }) => theme.font.color.secondary};
}
`;
export const RecordRelationFieldCardSection = () => {
@ -191,33 +212,54 @@ export const RecordRelationFieldCardSection = () => {
if (!relationLabelIdentifierFieldMetadata) return null;
const filterQueryParams: FilterQueryParams = {
filter: {
[relationFieldMetadataItem?.name || '']: {
[ViewFilterOperand.Is]: [entityId],
},
},
};
const filterLinkHref = `/objects/${
relationObjectMetadataItem.namePlural
}?${qs.stringify(filterQueryParams)}`;
return (
<Section>
<StyledHeader isDropdownOpen={isDropdownOpen}>
<StyledTitle>{fieldDefinition.label}</StyledTitle>
<StyledAddDropdown
dropdownId={dropdownId}
dropdownPlacement="right-start"
onClose={handleCloseRelationPickerDropdown}
clickableComponent={
<LightIconButton
className="displayOnHover"
Icon={IconPlus}
accent="tertiary"
/>
}
dropdownComponents={
<SingleEntitySelectMenuItemsWithSearch
EmptyIcon={IconForbid}
entitiesToSelect={entities.entitiesToSelect}
loading={entities.loading}
onEntitySelected={handleRelationPickerEntitySelected}
/>
}
dropdownHotkeyScope={{
scope: dropdownId,
}}
/>
<StyledTitle>
<StyledTitleLabel>{fieldDefinition.label}</StyledTitleLabel>
{parseFieldRelationType(relationFieldMetadataItem) ===
'TO_ONE_OBJECT' && (
<StyledLink to={filterLinkHref}>
All ({relationRecords.length})
</StyledLink>
)}
</StyledTitle>
<DropdownScope dropdownScopeId={dropdownId}>
<StyledAddDropdown
dropdownId={dropdownId}
dropdownPlacement="right-start"
onClose={handleCloseRelationPickerDropdown}
clickableComponent={
<LightIconButton
className="displayOnHover"
Icon={IconPlus}
accent="tertiary"
/>
}
dropdownComponents={
<SingleEntitySelectMenuItemsWithSearch
EmptyIcon={IconForbid}
entitiesToSelect={entities.entitiesToSelect}
loading={entities.loading}
onEntitySelected={handleRelationPickerEntitySelected}
/>
}
dropdownHotkeyScope={{
scope: dropdownId,
}}
/>
</DropdownScope>
</StyledHeader>
{!!relationRecords.length && (
<Card>

View File

@ -0,0 +1,39 @@
import { useEffect } from 'react';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { useFiltersFromQueryParams } from '@/views/hooks/internal/useFiltersFromQueryParams';
import { useViewScopedStates } from '@/views/hooks/internal/useViewScopedStates';
import { useViewBar } from '@/views/hooks/useViewBar';
export const FilterQueryParamsEffect = () => {
const { hasFiltersQueryParams, getFiltersFromQueryParams } =
useFiltersFromQueryParams();
const { currentViewFiltersState, onViewFiltersChangeState } =
useViewScopedStates();
const setCurrentViewFilters = useSetRecoilState(currentViewFiltersState);
const onViewFiltersChange = useRecoilValue(onViewFiltersChangeState);
const { resetViewBar } = useViewBar();
useEffect(() => {
if (!hasFiltersQueryParams) return;
getFiltersFromQueryParams().then((filtersFromParams) => {
if (Array.isArray(filtersFromParams)) {
setCurrentViewFilters(filtersFromParams);
onViewFiltersChange?.(filtersFromParams);
}
});
return () => {
resetViewBar();
};
}, [
getFiltersFromQueryParams,
hasFiltersQueryParams,
onViewFiltersChange,
resetViewBar,
setCurrentViewFilters,
]);
return null;
};

View File

@ -1,10 +1,12 @@
import { ReactNode } from 'react';
import { useParams } from 'react-router-dom';
import { ObjectFilterDropdownButton } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownButton';
import { FiltersHotkeyScope } from '@/object-record/object-filter-dropdown/types/FiltersHotkeyScope';
import { ObjectSortDropdownButton } from '@/object-record/object-sort-dropdown/components/ObjectSortDropdownButton';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { TopBar } from '@/ui/layout/top-bar/TopBar';
import { FilterQueryParamsEffect } from '@/views/components/FilterQueryParamsEffect';
import { ViewBarFilterEffect } from '@/views/components/ViewBarFilterEffect';
import { ViewBarSortEffect } from '@/views/components/ViewBarSortEffect';
import { useViewBar } from '@/views/hooks/useViewBar';
@ -45,6 +47,7 @@ export const ViewBar = ({
const { upsertViewSort, upsertViewFilter } = useViewBar({
viewBarId: viewBarId,
});
const { objectNamePlural } = useParams();
const filterDropdownId = 'view-filter';
const sortDropdownId = 'view-sort';
@ -65,6 +68,7 @@ export const ViewBar = ({
sortDropdownId={sortDropdownId}
onSortSelect={upsertViewSort}
/>
{!!objectNamePlural && <FilterQueryParamsEffect />}
<TopBar
className={className}

View File

@ -5,6 +5,7 @@ import { useRecoilValue } from 'recoil';
import { AddObjectFilterFromDetailsButton } from '@/object-record/object-filter-dropdown/components/AddObjectFilterFromDetailsButton';
import { ObjectFilterDropdownScope } from '@/object-record/object-filter-dropdown/scopes/ObjectFilterDropdownScope';
import { FiltersHotkeyScope } from '@/object-record/object-filter-dropdown/types/FiltersHotkeyScope';
import { DropdownScope } from '@/ui/layout/dropdown/scopes/DropdownScope';
import { EditableFilterDropdownButton } from '@/views/components/EditableFilterDropdownButton';
import { EditableSortChip } from '@/views/components/EditableSortChip';
import { ViewBarFilterEffect } from '@/views/components/ViewBarFilterEffect';
@ -132,19 +133,20 @@ export const ViewBarDetails = ({
<StyledBar>
<StyledFilterContainer>
<StyledChipcontainer>
{currentViewSorts?.map((sort) => {
return <EditableSortChip viewSort={sort} />;
})}
{currentViewSorts?.map((sort) => (
<EditableSortChip key={sort.id} viewSort={sort} />
))}
{!!currentViewSorts?.length && !!currentViewFilters?.length && (
<StyledSeperatorContainer>
<StyledSeperator />
</StyledSeperatorContainer>
)}
{currentViewFilters?.map((viewFilter) => {
return (
<ObjectFilterDropdownScope
filterScopeId={viewFilter.fieldMetadataId}
>
{currentViewFilters?.map((viewFilter) => (
<ObjectFilterDropdownScope
key={viewFilter.id}
filterScopeId={viewFilter.fieldMetadataId}
>
<DropdownScope dropdownScopeId={viewFilter.fieldMetadataId}>
<ViewBarFilterEffect
filterDropdownId={viewFilter.fieldMetadataId}
onFilterSelect={upsertViewFilter}
@ -156,9 +158,9 @@ export const ViewBarDetails = ({
}}
viewFilterDropdownId={viewFilter.fieldMetadataId}
/>
</ObjectFilterDropdownScope>
);
})}
</DropdownScope>
</ObjectFilterDropdownScope>
))}
</StyledChipcontainer>
{hasFilterButton && (
<StyledAddFilterContainer>

View File

@ -27,7 +27,7 @@ export const ViewBarFilterEffect = ({
filterDefinitionUsedInDropdown,
setObjectFilterDropdownSelectedRecordIds,
isObjectFilterDropdownUnfolded,
} = useFilterDropdown({ filterDropdownId: filterDropdownId });
} = useFilterDropdown({ filterDropdownId });
useEffect(() => {
if (availableFilterDefinitions) {

View File

@ -0,0 +1,160 @@
import { useMemo } from 'react';
import { useParams, useSearchParams } from 'react-router-dom';
import { useApolloClient } from '@apollo/client';
import qs from 'qs';
import { useRecoilCallback } from 'recoil';
import z from 'zod';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectNameSingularFromPlural } from '@/object-metadata/hooks/useObjectNameSingularFromPlural';
import { objectMetadataItemFamilySelector } from '@/object-metadata/states/objectMetadataItemFamilySelector';
import { formatFieldMetadataItemAsFilterDefinition } from '@/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions';
import { getObjectRecordIdentifier } from '@/object-metadata/utils/getObjectRecordIdentifier';
import { useGenerateFindManyRecordsQuery } from '@/object-record/hooks/useGenerateFindManyRecordsQuery';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { ViewFilter } from '@/views/types/ViewFilter';
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
import { assertNotNull } from '~/utils/assert';
const filterQueryParamsSchema = z.object({
filter: z.record(
z.record(
z.nativeEnum(ViewFilterOperand),
z.string().or(z.array(z.string())),
),
),
});
export type FilterQueryParams = z.infer<typeof filterQueryParamsSchema>;
export const useFiltersFromQueryParams = () => {
const apolloClient = useApolloClient();
const [searchParams] = useSearchParams();
const { objectNamePlural = '' } = useParams();
const { objectNameSingular } = useObjectNameSingularFromPlural({
objectNamePlural,
});
const { objectMetadataItem } = useObjectMetadataItem({ objectNameSingular });
const generateFindManyRecordsQuery = useGenerateFindManyRecordsQuery();
const filterParamsValidation = filterQueryParamsSchema.safeParse(
qs.parse(searchParams.toString()),
);
const filterQueryParams = useMemo(
() =>
filterParamsValidation.success ? filterParamsValidation.data.filter : {},
[filterParamsValidation],
);
const hasFiltersQueryParams = filterParamsValidation.success;
const getFiltersFromQueryParams = useRecoilCallback(
({ snapshot }) =>
async () => {
if (!hasFiltersQueryParams) return [];
return (
await Promise.all(
Object.entries(filterQueryParams).map<Promise<ViewFilter | null>>(
async ([fieldName, filterFromURL]) => {
const [filterOperandFromURL, filterValueFromURL] =
Object.entries(filterFromURL)[0];
const fieldMetadataItem = objectMetadataItem.fields.find(
(field) => field.name === fieldName,
);
if (!fieldMetadataItem) return null;
const filterDefinition =
formatFieldMetadataItemAsFilterDefinition({
field: fieldMetadataItem,
});
if (!filterDefinition) return null;
const relationObjectMetadataNameSingular =
fieldMetadataItem.toRelationMetadata?.fromObjectMetadata
.nameSingular;
const relationObjectMetadataNamePlural =
fieldMetadataItem.toRelationMetadata?.fromObjectMetadata
.namePlural;
const relationObjectMetadataItem =
relationObjectMetadataNameSingular
? snapshot
.getLoadable(
objectMetadataItemFamilySelector({
objectName: relationObjectMetadataNameSingular,
objectNameType: 'singular',
}),
)
.getValue()
: null;
const relationRecordNames = [];
if (
relationObjectMetadataNamePlural &&
relationObjectMetadataItem &&
Array.isArray(filterValueFromURL)
) {
const queryResult = await apolloClient.query<
Record<string, { edges: { node: ObjectRecord }[] }>
>({
query: generateFindManyRecordsQuery({
objectMetadataItem: relationObjectMetadataItem,
}),
variables: {
filter: { id: { in: filterValueFromURL } },
},
});
const relationRecordNamesFromQuery = queryResult.data?.[
relationObjectMetadataNamePlural
]?.edges.map(
({ node: record }) =>
getObjectRecordIdentifier({
objectMetadataItem: relationObjectMetadataItem,
record,
}).name,
);
relationRecordNames.push(...relationRecordNamesFromQuery);
}
const filterValueAsString = Array.isArray(filterValueFromURL)
? JSON.stringify(filterValueFromURL)
: filterValueFromURL;
return {
id: `tmp-${[
fieldName,
filterOperandFromURL,
filterValueFromURL,
].join('-')}`,
fieldMetadataId: fieldMetadataItem.id,
operand: filterOperandFromURL as ViewFilterOperand,
value: filterValueAsString,
displayValue:
relationRecordNames?.join(', ') ?? filterValueAsString,
definition: filterDefinition,
};
},
),
)
).filter(assertNotNull);
},
[
apolloClient,
filterQueryParams,
generateFindManyRecordsQuery,
hasFiltersQueryParams,
objectMetadataItem.fields,
],
);
return {
hasFiltersQueryParams,
getFiltersFromQueryParams,
};
};

View File

@ -43,13 +43,7 @@ export const useViewFilters = (viewScopeId: string) => {
viewScopeId,
});
if (!currentViewId) {
return;
}
if (!currentViewFilters) {
return;
}
if (!savedViewFiltersByKey) {
if (!currentViewId || !currentViewFilters || !savedViewFiltersByKey) {
return;
}

View File

@ -85,7 +85,10 @@ export const useViewBar = (props?: UseViewProps) => {
const changeViewInUrl = useCallback(
(viewId: string) => {
setSearchParams({ view: viewId });
setSearchParams((previousSearchParams) => {
previousSearchParams.set('view', viewId);
return previousSearchParams;
});
},
[setSearchParams],
);

679
yarn.lock

File diff suppressed because it is too large Load Diff