diff --git a/.changeset/deps-list-filter-sort.md b/.changeset/deps-list-filter-sort.md new file mode 100644 index 00000000000..62e789bc8f0 --- /dev/null +++ b/.changeset/deps-list-filter-sort.md @@ -0,0 +1,55 @@ +--- +"@comet/cms-admin": major +"@comet/cms-api": major +"@comet/admin": minor +--- + +Add filtering and sorting to `DependenciesList` and `DependentsList` + +Users can now filter dependencies/dependents by name, type, secondary information, and visibility, and sort by all columns. A default filter shows only visible items. The `GqlFilter` type is now exported from `@comet/admin`. + +**Breaking changes:** + +**`@comet/cms-api`:** `DependencyFilter.targetGraphqlObjectType` and `DependentFilter.rootGraphqlObjectType` changed from `string` to `StringFilter`. Update any code passing a plain string to use `{ equal: "..." }` instead. + +**`@comet/cms-admin`:** The GQL queries passed to `DependenciesList` and `DependentsList` must now accept `$filter` and `$sort` variables and forward them to the `dependencies`/`dependents` field. Update your queries as follows: + +```graphql +# DependentsList +query MyDependents($id: ID!, $offset: Int!, $limit: Int!, $forceRefresh: Boolean = false, $filter: DependentFilter, $sort: [DependencySort!]) { + item: myEntity(id: $id) { + id + dependents(offset: $offset, limit: $limit, forceRefresh: $forceRefresh, filter: $filter, sort: $sort) { + nodes { + rootGraphqlObjectType + rootId + rootColumnName + jsonPath + name + secondaryInformation + visible + } + totalCount + } + } +} + +# DependenciesList +query MyDependencies($id: ID!, $offset: Int!, $limit: Int!, $forceRefresh: Boolean = false, $filter: DependencyFilter, $sort: [DependencySort!]) { + item: myEntity(id: $id) { + id + dependencies(offset: $offset, limit: $limit, forceRefresh: $forceRefresh, filter: $filter, sort: $sort) { + nodes { + targetGraphqlObjectType + targetId + rootColumnName + jsonPath + name + secondaryInformation + visible + } + totalCount + } + } +} +``` diff --git a/demo/admin/src/documents/pages/EditPage.tsx b/demo/admin/src/documents/pages/EditPage.tsx index 249b5a19961..7109edb3ea2 100644 --- a/demo/admin/src/documents/pages/EditPage.tsx +++ b/demo/admin/src/documents/pages/EditPage.tsx @@ -37,10 +37,17 @@ interface Props { } const pageTreeNodeDependentsQuery = gql` - query PageTreeNodeDependents($id: ID!, $offset: Int!, $limit: Int!, $forceRefresh: Boolean = false) { + query PageTreeNodeDependents( + $id: ID! + $offset: Int! + $limit: Int! + $forceRefresh: Boolean = false + $filter: DependentFilter + $sort: [DependencySort!] + ) { item: pageTreeNode(id: $id) { id - dependents(offset: $offset, limit: $limit, forceRefresh: $forceRefresh) { + dependents(offset: $offset, limit: $limit, forceRefresh: $forceRefresh, filter: $filter, sort: $sort) { nodes { rootGraphqlObjectType rootId @@ -48,6 +55,7 @@ const pageTreeNodeDependentsQuery = gql` jsonPath name secondaryInformation + visible } totalCount } @@ -56,10 +64,17 @@ const pageTreeNodeDependentsQuery = gql` `; const pageTreeNodeDependenciesQuery = gql` - query PageTreeNodeDependencies($id: ID!, $offset: Int!, $limit: Int!, $forceRefresh: Boolean = false) { + query PageTreeNodeDependencies( + $id: ID! + $offset: Int! + $limit: Int! + $forceRefresh: Boolean = false + $filter: DependencyFilter + $sort: [DependencySort!] + ) { item: page(id: $id) { id - dependencies(offset: $offset, limit: $limit, forceRefresh: $forceRefresh) { + dependencies(offset: $offset, limit: $limit, forceRefresh: $forceRefresh, filter: $filter, sort: $sort) { nodes { targetGraphqlObjectType targetId @@ -67,6 +82,7 @@ const pageTreeNodeDependenciesQuery = gql` jsonPath name secondaryInformation + visible } totalCount } diff --git a/demo/api/schema.gql b/demo/api/schema.gql index 6c269ef96e7..fabea42d6f8 100644 --- a/demo/api/schema.gql +++ b/demo/api/schema.gql @@ -381,7 +381,7 @@ type DamFile { contentHash: String! createdAt: DateTime! damPath: String! - dependents(filter: DependentFilter, forceRefresh: Boolean! = false, limit: Int! = 25, offset: Int! = 0): PaginatedDependencies! + dependents(filter: DependentFilter, forceRefresh: Boolean! = false, limit: Int! = 25, offset: Int! = 0, sort: [DependencySort!]): PaginatedDependencies! duplicates: [DamFile!]! fileUrl: String! folder: DamFolder @@ -542,15 +542,37 @@ type Dependency { } input DependencyFilter { + and: [DependencyFilter!] + name: StringFilter + or: [DependencyFilter!] rootColumnName: String - targetGraphqlObjectType: String + secondaryInformation: StringFilter + targetGraphqlObjectType: StringFilter targetId: String + visible: BooleanFilter +} + +input DependencySort { + direction: SortDirection! = ASC + field: DependencySortField! +} + +enum DependencySortField { + graphqlObjectType + name + secondaryInformation + visible } input DependentFilter { + and: [DependentFilter!] + name: StringFilter + or: [DependentFilter!] rootColumnName: String - rootGraphqlObjectType: String + rootGraphqlObjectType: StringFilter rootId: String + secondaryInformation: StringFilter + visible: BooleanFilter } interface DocumentInterface { @@ -669,7 +691,7 @@ input FolderFilterInput { type Footer { content: FooterContentBlockData! createdAt: DateTime! - dependencies(filter: DependencyFilter, forceRefresh: Boolean! = false, limit: Int! = 25, offset: Int! = 0): PaginatedDependencies! + dependencies(filter: DependencyFilter, forceRefresh: Boolean! = false, limit: Int! = 25, offset: Int! = 0, sort: [DependencySort!]): PaginatedDependencies! id: ID! scope: FooterScope! updatedAt: DateTime! @@ -799,7 +821,7 @@ type MainMenu { type MainMenuItem { content: RichTextBlockData createdAt: DateTime! - dependencies(filter: DependencyFilter, forceRefresh: Boolean! = false, limit: Int! = 25, offset: Int! = 0): PaginatedDependencies! + dependencies(filter: DependencyFilter, forceRefresh: Boolean! = false, limit: Int! = 25, offset: Int! = 0, sort: [DependencySort!]): PaginatedDependencies! id: ID! node: PageTreeNode! updatedAt: DateTime! @@ -1011,8 +1033,8 @@ type News { content: NewsContentBlockData! createdAt: DateTime! date: DateTime! - dependencies(filter: DependencyFilter, forceRefresh: Boolean! = false, limit: Int! = 25, offset: Int! = 0): PaginatedDependencies! - dependents(filter: DependentFilter, forceRefresh: Boolean! = false, limit: Int! = 25, offset: Int! = 0): PaginatedDependencies! + dependencies(filter: DependencyFilter, forceRefresh: Boolean! = false, limit: Int! = 25, offset: Int! = 0, sort: [DependencySort!]): PaginatedDependencies! + dependents(filter: DependentFilter, forceRefresh: Boolean! = false, limit: Int! = 25, offset: Int! = 0, sort: [DependencySort!]): PaginatedDependencies! id: ID! image: DamImageBlockData! scope: NewsContentScope! @@ -1143,7 +1165,7 @@ input OneToManyFilter { type Page implements DocumentInterface { content: PageContentBlockData! createdAt: DateTime! - dependencies(filter: DependencyFilter, forceRefresh: Boolean! = false, limit: Int! = 25, offset: Int! = 0): PaginatedDependencies! + dependencies(filter: DependencyFilter, forceRefresh: Boolean! = false, limit: Int! = 25, offset: Int! = 0, sort: [DependencySort!]): PaginatedDependencies! id: ID! pageTreeNode: PageTreeNode seo: SeoBlockData! @@ -1168,8 +1190,8 @@ input PageInput { type PageTreeNode { category: PageTreeNodeCategory! childNodes: [PageTreeNode!]! - dependencies(filter: DependencyFilter, forceRefresh: Boolean! = false, limit: Int! = 25, offset: Int! = 0): PaginatedDependencies! - dependents(filter: DependentFilter, forceRefresh: Boolean! = false, limit: Int! = 25, offset: Int! = 0): PaginatedDependencies! + dependencies(filter: DependencyFilter, forceRefresh: Boolean! = false, limit: Int! = 25, offset: Int! = 0, sort: [DependencySort!]): PaginatedDependencies! + dependents(filter: DependentFilter, forceRefresh: Boolean! = false, limit: Int! = 25, offset: Int! = 0, sort: [DependencySort!]): PaginatedDependencies! document: PageContentUnion documentType: String! hideInMenu: Boolean! @@ -1978,7 +2000,7 @@ type Redirect { active: Boolean! comment: String createdAt: DateTime! - dependencies(filter: DependencyFilter, forceRefresh: Boolean! = false, limit: Int! = 25, offset: Int! = 0): PaginatedDependencies! + dependencies(filter: DependencyFilter, forceRefresh: Boolean! = false, limit: Int! = 25, offset: Int! = 0, sort: [DependencySort!]): PaginatedDependencies! generationType: RedirectGenerationType! id: ID! scope: RedirectScope! diff --git a/docs/docs/2-core-concepts/7-dependencies/index.md b/docs/docs/2-core-concepts/7-dependencies/index.md index 4f219e82c62..8f3bf2b5c21 100644 --- a/docs/docs/2-core-concepts/7-dependencies/index.md +++ b/docs/docs/2-core-concepts/7-dependencies/index.md @@ -306,10 +306,12 @@ Each component requires two props: $offset: Int! $limit: Int! $forceRefresh: Boolean = false + $filter: DependentFilter + $sort: [DependencySort!] ) { item: damFile(id: $id) { id - dependents(offset: $offset, limit: $limit, forceRefresh: $forceRefresh) { + dependents(offset: $offset, limit: $limit, forceRefresh: $forceRefresh, filter: $filter, sort: $sort) { nodes { rootGraphqlObjectType rootId @@ -317,6 +319,7 @@ Each component requires two props: jsonPath name secondaryInformation + visible } totalCount } diff --git a/packages/admin/admin/src/index.ts b/packages/admin/admin/src/index.ts index de8d226cb4e..139e811baa7 100644 --- a/packages/admin/admin/src/index.ts +++ b/packages/admin/admin/src/index.ts @@ -84,7 +84,7 @@ export { dataGridOneToManyColumn, } from "./dataGrid/gridColumnTypes"; export { GridFilterButton } from "./dataGrid/GridFilterButton"; -export { muiGridFilterToGql } from "./dataGrid/muiGridFilterToGql"; +export { type GqlFilter, muiGridFilterToGql } from "./dataGrid/muiGridFilterToGql"; export { muiGridPagingToGql } from "./dataGrid/muiGridPagingToGql"; export { muiGridSortToGql } from "./dataGrid/muiGridSortToGql"; export { DataGridPagination, type DataGridPaginationClassKey, type DataGridPaginationProps } from "./dataGrid/pagination/DataGridPagination"; diff --git a/packages/admin/cms-admin/src/dam/FileForm/EditFile.gql.ts b/packages/admin/cms-admin/src/dam/FileForm/EditFile.gql.ts index f9b77872143..16fd7018e8f 100644 --- a/packages/admin/cms-admin/src/dam/FileForm/EditFile.gql.ts +++ b/packages/admin/cms-admin/src/dam/FileForm/EditFile.gql.ts @@ -59,10 +59,17 @@ export const updateDamFileMutation = gql` `; export const damFileDependentsQuery = gql` - query DamFileDependencies($id: ID!, $offset: Int!, $limit: Int!, $forceRefresh: Boolean = false) { + query DamFileDependencies( + $id: ID! + $offset: Int! + $limit: Int! + $forceRefresh: Boolean = false + $filter: DependentFilter + $sort: [DependencySort!] + ) { item: damFile(id: $id) { id - dependents(offset: $offset, limit: $limit, forceRefresh: $forceRefresh) { + dependents(offset: $offset, limit: $limit, forceRefresh: $forceRefresh, filter: $filter, sort: $sort) { nodes { rootGraphqlObjectType rootId @@ -70,6 +77,7 @@ export const damFileDependentsQuery = gql` jsonPath name secondaryInformation + visible } totalCount } diff --git a/packages/admin/cms-admin/src/dependencies/DependenciesList.tsx b/packages/admin/cms-admin/src/dependencies/DependenciesList.tsx index c250fa08d0e..c211283aada 100644 --- a/packages/admin/cms-admin/src/dependencies/DependenciesList.tsx +++ b/packages/admin/cms-admin/src/dependencies/DependenciesList.tsx @@ -3,9 +3,13 @@ import { Alert, DataGridToolbar, FillSpace, + type GqlFilter, GridCellContent, type GridColDef, + GridFilterButton, messages, + muiGridFilterToGql, + muiGridSortToGql, Tooltip, useBufferedRowCount, useDataGridRemote, @@ -14,7 +18,7 @@ import { import { ArrowRight, OpenNewTab, Reload, ThreeDotSaving } from "@comet/admin-icons"; import { Chip, IconButton } from "@mui/material"; import { DataGrid, type GridSlotsComponent, type GridToolbarProps } from "@mui/x-data-grid"; -import { useState } from "react"; +import { isValidElement, type ReactNode, useMemo, useState } from "react"; import { FormattedMessage, useIntl } from "react-intl"; import { useHistory } from "react-router"; @@ -23,12 +27,15 @@ import { type GQLDependency } from "../graphql.generated"; import { useDependenciesConfig } from "./dependenciesConfig"; import { type DependencyInterface } from "./types"; -type DependencyItem = Pick & { +type DependencyItem = Pick & { id: string; graphqlObjectType: string; }; -type Dependency = Pick; +type Dependency = Pick< + GQLDependency, + "targetGraphqlObjectType" | "targetId" | "rootColumnName" | "jsonPath" | "name" | "secondaryInformation" | "visible" +>; interface DependenciesListQuery { item: { @@ -41,6 +48,8 @@ type QueryVariables = { offset: number; limit: number; forceRefresh?: boolean; + filter?: GqlFilter; + sort?: Array<{ field: string; direction: "ASC" | "DESC" }>; }; interface DependenciesListGridToolbarProps extends GridToolbarProps { @@ -51,6 +60,7 @@ function DependenciesListGridToolbar({ refetch }: DependenciesListGridToolbarPro return ( + }> ...useDataGridRemote({ queryParamsPrefix: "dependencies", pageSize, + initialFilter: { + items: [{ field: "visible", operator: "is", value: "true" }], + }, }), ...usePersistentColumnState("DependenciesList"), }; - const columns: GridColDef[] = [ - { - field: "nameInfo", - headerName: intl.formatMessage({ id: "comet.dependencies.dataGrid.nameAndInfo", defaultMessage: "Name/Info" }), - sortable: false, - flex: 1, - renderCell: ({ row }) => ( - } secondaryText={row.secondaryInformation} /> - ), - }, - { - field: "type", - headerName: intl.formatMessage({ id: "comet.dependencies.dataGrid.type", defaultMessage: "Type" }), - sortable: false, - renderCell: ({ row }) => , - }, - { - field: "actions", - type: "actions", - headerName: "", - sortable: false, - renderCell: ({ row }) => { - const dependencyObject = entityDependencyMap[row.graphqlObjectType] as DependencyInterface | undefined; - - if (dependencyObject === undefined) { - if (process.env.NODE_ENV === "development") { - console.warn( - `Cannot load URL because no implementation of DependencyInterface for ${row.graphqlObjectType} was provided via the DependenciesConfig`, - ); + const columns: GridColDef[] = useMemo( + () => [ + { + field: "name", + headerName: intl.formatMessage({ id: "comet.dependencies.dataGrid.nameAndInfo", defaultMessage: "Name/Info" }), + flex: 1, + sortBy: "name", + renderCell: ({ row }) => ( + } secondaryText={row.secondaryInformation} /> + ), + }, + { + field: "secondaryInformation", + headerName: intl.formatMessage({ id: "comet.dependencies.dataGrid.secondaryInformation", defaultMessage: "Secondary information" }), + sortBy: "secondaryInformation", + visible: false, + }, + { + field: "type", + headerName: intl.formatMessage({ id: "comet.dependencies.dataGrid.type", defaultMessage: "Type" }), + type: "singleSelect", + valueOptions: Object.entries(entityDependencyMap).map(([value, dep]) => ({ + value, + label: getDisplayNameString(dep.displayName, value), + })), + sortBy: "graphqlObjectType", + toGqlFilter: (filterItem) => { + if (!filterItem.value) return {}; + if (filterItem.operator === "isAnyOf") { + return { targetGraphqlObjectType: { isAnyOf: filterItem.value } }; + } + if (filterItem.operator === "not") { + return { targetGraphqlObjectType: { notEqual: filterItem.value } }; } - return ; - } - - const loadUrl = async () => { - const path = await dependencyObject.resolvePath({ - rootColumnName: row.rootColumnName, - jsonPath: row.jsonPath, - apolloClient, - id: row.id, - }); - return contentScope.match.url + path; - }; - - return ( -
- { - const url = await loadUrl(); - window.open(url, "_blank"); - }} - > - - - { - const url = await loadUrl(); - - history.push(url); - }} - > - - -
- ); + return { targetGraphqlObjectType: { equal: filterItem.value } }; + }, + renderCell: ({ row }) => , }, - }, - ]; + { + field: "visible", + headerName: intl.formatMessage({ id: "comet.dependencies.dataGrid.visible", defaultMessage: "Visible" }), + type: "boolean", + visible: false, + sortBy: "visible", + toGqlFilter: (filterItem) => { + if (filterItem.value === undefined || filterItem.value === "") return {}; + return { visible: { equal: filterItem.value === "true" || filterItem.value === true } } as unknown as GqlFilter; + }, + }, + { + field: "actions", + type: "actions", + headerName: "", + filterable: false, + sortable: false, + renderCell: ({ row }) => { + const dependencyObject = entityDependencyMap[row.graphqlObjectType] as DependencyInterface | undefined; + + if (dependencyObject === undefined) { + if (process.env.NODE_ENV === "development") { + console.warn( + `Cannot load URL because no implementation of DependencyInterface for ${row.graphqlObjectType} was provided via the DependenciesConfig`, + ); + } + return ; + } + + const loadUrl = async () => { + const path = await dependencyObject.resolvePath({ + rootColumnName: row.rootColumnName, + jsonPath: row.jsonPath, + apolloClient, + id: row.id, + }); + return contentScope.match.url + path; + }; + + return ( +
+ { + const url = await loadUrl(); + window.open(url, "_blank"); + }} + > + + + { + const url = await loadUrl(); + + history.push(url); + }} + > + + +
+ ); + }, + }, + ], + [intl, entityDependencyMap, apolloClient, contentScope, history], + ); + + const { filter: gqlFilter } = muiGridFilterToGql(columns, dataGridProps.filterModel); + const sort = muiGridSortToGql(dataGridProps.sortModel, columns); const { data, loading, error, refetch } = useQuery(query, { variables: { offset: dataGridProps.paginationModel.page * dataGridProps.paginationModel.pageSize, limit: dataGridProps.paginationModel.pageSize, + filter: gqlFilter, + sort, ...variables, }, }); diff --git a/packages/admin/cms-admin/src/dependencies/DependentsList.tsx b/packages/admin/cms-admin/src/dependencies/DependentsList.tsx index e9a554d78fb..5aed9f7f22e 100644 --- a/packages/admin/cms-admin/src/dependencies/DependentsList.tsx +++ b/packages/admin/cms-admin/src/dependencies/DependentsList.tsx @@ -3,9 +3,13 @@ import { Alert, DataGridToolbar, FillSpace, + type GqlFilter, GridCellContent, type GridColDef, + GridFilterButton, messages, + muiGridFilterToGql, + muiGridSortToGql, Tooltip, useBufferedRowCount, useDataGridRemote, @@ -14,7 +18,7 @@ import { import { ArrowRight, OpenNewTab, Reload, ThreeDotSaving } from "@comet/admin-icons"; import { Chip, IconButton } from "@mui/material"; import { DataGrid, type GridSlotsComponent, type GridToolbarProps } from "@mui/x-data-grid"; -import { useState } from "react"; +import { isValidElement, type ReactNode, useMemo, useState } from "react"; import { FormattedMessage, useIntl } from "react-intl"; import { useHistory } from "react-router"; @@ -23,12 +27,15 @@ import { type GQLDependency } from "../graphql.generated"; import { useDependenciesConfig } from "./dependenciesConfig"; import { type DependencyInterface } from "./types"; -type DependencyItem = Pick & { +type DependencyItem = Pick & { id: string; graphqlObjectType: string; }; -type Dependent = Pick; +type Dependent = Pick< + GQLDependency, + "rootGraphqlObjectType" | "rootId" | "rootColumnName" | "jsonPath" | "name" | "secondaryInformation" | "visible" +>; interface DependentsListQuery { item: { @@ -41,6 +48,8 @@ type QueryVariables = { offset: number; limit: number; forceRefresh?: boolean; + filter?: GqlFilter; + sort?: Array<{ field: string; direction: "ASC" | "DESC" }>; }; interface DependentsListGridToolbarProps extends GridToolbarProps { @@ -51,6 +60,7 @@ function DependentsListGridToolbar({ refetch }: DependentsListGridToolbarProps) return ( + }> { ...useDataGridRemote({ queryParamsPrefix: "dependents", pageSize, + initialFilter: { + items: [{ field: "visible", operator: "is", value: "true" }], + }, }), ...usePersistentColumnState("DependentsList"), }; - const columns: GridColDef[] = [ - { - field: "nameInfo", - headerName: intl.formatMessage({ id: "comet.dependencies.dataGrid.nameAndInfo", defaultMessage: "Name/Info" }), - sortable: false, - flex: 1, - renderCell: ({ row }) => ( - } secondaryText={row.secondaryInformation} /> - ), - }, - { - field: "type", - headerName: intl.formatMessage({ id: "comet.dependencies.dataGrid.type", defaultMessage: "Type" }), - sortable: false, - renderCell: ({ row }) => , - }, - { - field: "actions", - type: "actions", - headerName: "", - sortable: false, - renderCell: ({ row }) => { - const dependencyObject = entityDependencyMap[row.graphqlObjectType] as DependencyInterface | undefined; - - if (dependencyObject === undefined) { - if (process.env.NODE_ENV === "development") { - console.warn( - `Cannot load URL because no implementation of DependencyInterface for ${row.graphqlObjectType} was provided via the DependenciesConfig`, - ); + const columns: GridColDef[] = useMemo( + () => [ + { + field: "name", + headerName: intl.formatMessage({ id: "comet.dependencies.dataGrid.nameAndInfo", defaultMessage: "Name/Info" }), + flex: 1, + sortBy: "name", + renderCell: ({ row }) => ( + } secondaryText={row.secondaryInformation} /> + ), + }, + { + field: "secondaryInformation", + headerName: intl.formatMessage({ id: "comet.dependencies.dataGrid.secondaryInformation", defaultMessage: "Secondary information" }), + sortBy: "secondaryInformation", + visible: false, + }, + { + field: "type", + headerName: intl.formatMessage({ id: "comet.dependencies.dataGrid.type", defaultMessage: "Type" }), + type: "singleSelect", + valueOptions: Object.entries(entityDependencyMap).map(([value, dep]) => ({ + value, + label: getDisplayNameString(dep.displayName, value), + })), + sortBy: "graphqlObjectType", + toGqlFilter: (filterItem) => { + if (!filterItem.value) return {}; + if (filterItem.operator === "isAnyOf") { + return { rootGraphqlObjectType: { isAnyOf: filterItem.value } }; + } + if (filterItem.operator === "not") { + return { rootGraphqlObjectType: { notEqual: filterItem.value } }; } - return ; - } - - const loadUrl = async () => { - const path = await dependencyObject.resolvePath({ - rootColumnName: row.rootColumnName, - jsonPath: row.jsonPath, - apolloClient, - id: row.id, - }); - return contentScope.match.url + path; - }; - - return ( -
- { - const url = await loadUrl(); - window.open(url, "_blank"); - }} - > - - - { - const url = await loadUrl(); - - history.push(url); - }} - > - - -
- ); + return { rootGraphqlObjectType: { equal: filterItem.value } }; + }, + renderCell: ({ row }) => , }, - }, - ]; + { + field: "visible", + headerName: intl.formatMessage({ id: "comet.dependencies.dataGrid.visible", defaultMessage: "Visible" }), + type: "boolean", + visible: false, + sortBy: "visible", + toGqlFilter: (filterItem) => { + if (filterItem.value === undefined || filterItem.value === "") return {}; + return { visible: { equal: filterItem.value === "true" || filterItem.value === true } } as unknown as GqlFilter; + }, + }, + { + field: "actions", + type: "actions", + headerName: "", + filterable: false, + sortable: false, + renderCell: ({ row }) => { + const dependencyObject = entityDependencyMap[row.graphqlObjectType] as DependencyInterface | undefined; + + if (dependencyObject === undefined) { + if (process.env.NODE_ENV === "development") { + console.warn( + `Cannot load URL because no implementation of DependencyInterface for ${row.graphqlObjectType} was provided via the DependenciesConfig`, + ); + } + return ; + } + + const loadUrl = async () => { + const path = await dependencyObject.resolvePath({ + rootColumnName: row.rootColumnName, + jsonPath: row.jsonPath, + apolloClient, + id: row.id, + }); + return contentScope.match.url + path; + }; + + return ( +
+ { + const url = await loadUrl(); + window.open(url, "_blank"); + }} + > + + + { + const url = await loadUrl(); + + history.push(url); + }} + > + + +
+ ); + }, + }, + ], + [intl, entityDependencyMap, apolloClient, contentScope, history], + ); + + const { filter: gqlFilter } = muiGridFilterToGql(columns, dataGridProps.filterModel); + const sort = muiGridSortToGql(dataGridProps.sortModel, columns); const { data, loading, error, refetch } = useQuery(query, { variables: { offset: dataGridProps.paginationModel.page * dataGridProps.paginationModel.pageSize, limit: dataGridProps.paginationModel.pageSize, + filter: gqlFilter, + sort, ...variables, }, }); diff --git a/packages/api/cms-api/schema.gql b/packages/api/cms-api/schema.gql index e19621e2530..97ebff464ed 100644 --- a/packages/api/cms-api/schema.gql +++ b/packages/api/cms-api/schema.gql @@ -314,7 +314,7 @@ type PageTreeNode { path: String! parentNodes: [PageTreeNode!]! document: PageContentUnion - dependents(offset: Int! = 0, limit: Int! = 25, filter: DependentFilter, forceRefresh: Boolean! = false): PaginatedDependencies! + dependents(offset: Int! = 0, limit: Int! = 25, filter: DependentFilter, sort: [DependencySort!], forceRefresh: Boolean! = false): PaginatedDependencies! } enum PageTreeNodeVisibility { @@ -336,9 +336,47 @@ interface DocumentInterface { } input DependentFilter { - rootGraphqlObjectType: String + rootGraphqlObjectType: StringFilter rootId: String rootColumnName: String + name: StringFilter + secondaryInformation: StringFilter + visible: BooleanFilter + and: [DependentFilter!] + or: [DependentFilter!] +} + +input StringFilter { + contains: String + notContains: String + startsWith: String + endsWith: String + equal: String + notEqual: String + isAnyOf: [String!] + isEmpty: Boolean + isNotEmpty: Boolean +} + +input BooleanFilter { + equal: Boolean +} + +input DependencySort { + field: DependencySortField! + direction: SortDirection! = ASC +} + +enum DependencySortField { + name + secondaryInformation + graphqlObjectType + visible +} + +enum SortDirection { + ASC + DESC } type Redirect { @@ -352,7 +390,7 @@ type Redirect { generationType: RedirectGenerationType! createdAt: DateTime! updatedAt: DateTime! - dependencies(offset: Int! = 0, limit: Int! = 25, filter: DependencyFilter, forceRefresh: Boolean! = false): PaginatedDependencies! + dependencies(offset: Int! = 0, limit: Int! = 25, filter: DependencyFilter, sort: [DependencySort!], forceRefresh: Boolean! = false): PaginatedDependencies! } enum RedirectSourceTypeValues { @@ -366,9 +404,14 @@ enum RedirectGenerationType { } input DependencyFilter { - targetGraphqlObjectType: String + targetGraphqlObjectType: StringFilter targetId: String rootColumnName: String + name: StringFilter + secondaryInformation: StringFilter + visible: BooleanFilter + and: [DependencyFilter!] + or: [DependencyFilter!] } type PaginatedRedirects { @@ -416,7 +459,7 @@ type DamFile { damPath: String! alternativesForThisFile: [DamMediaAlternative!]! thisFileIsAlternativeFor: [DamMediaAlternative!]! - dependents(offset: Int! = 0, limit: Int! = 25, filter: DependentFilter, forceRefresh: Boolean! = false): PaginatedDependencies! + dependents(offset: Int! = 0, limit: Int! = 25, filter: DependentFilter, sort: [DependencySort!], forceRefresh: Boolean! = false): PaginatedDependencies! } type PaginatedDamItems { @@ -505,11 +548,6 @@ input RedirectScopeInput { thisScopeHasNoFields____: String } -enum SortDirection { - ASC - DESC -} - input RedirectFilter { generationType: StringFilter source: StringFilter @@ -523,22 +561,6 @@ input RedirectFilter { or: [RedirectFilter!] } -input StringFilter { - contains: String - notContains: String - startsWith: String - endsWith: String - equal: String - notEqual: String - isAnyOf: [String!] - isEmpty: Boolean - isNotEmpty: Boolean -} - -input BooleanFilter { - equal: Boolean -} - input RedirectSourceTypeEnumFilter { isAnyOf: [RedirectSourceTypeValues!] equal: RedirectSourceTypeValues diff --git a/packages/api/cms-api/src/dependencies/dependencies.resolver.factory.ts b/packages/api/cms-api/src/dependencies/dependencies.resolver.factory.ts index d5c4e9fbba7..7a9da1fef95 100644 --- a/packages/api/cms-api/src/dependencies/dependencies.resolver.factory.ts +++ b/packages/api/cms-api/src/dependencies/dependencies.resolver.factory.ts @@ -17,9 +17,9 @@ export class DependenciesResolverFactory { @ResolveField(() => PaginatedDependencies) async dependencies( @Parent() node: AnyEntity<{ id: string }>, - @Args() { filter, offset, limit, forceRefresh }: DependenciesArgs, + @Args() { filter, sort, offset, limit, forceRefresh }: DependenciesArgs, ): Promise { - return this.dependenciesService.getDependencies(node, filter, { offset, limit }, { forceRefresh }); + return this.dependenciesService.getDependencies(node, filter, { offset, limit }, { forceRefresh, sort }); } } diff --git a/packages/api/cms-api/src/dependencies/dependencies.service.ts b/packages/api/cms-api/src/dependencies/dependencies.service.ts index 1a8d9f23a52..c0c2d53ced8 100644 --- a/packages/api/cms-api/src/dependencies/dependencies.service.ts +++ b/packages/api/cms-api/src/dependencies/dependencies.service.ts @@ -3,10 +3,12 @@ import { Injectable, Logger } from "@nestjs/common"; import { subMinutes } from "date-fns"; import { v4 as uuid } from "uuid"; +import { StringFilter } from "../common/filter/string.filter"; import { EntityInfoService } from "../entity-info/entity-info.service"; import { DiscoverService } from "./discover.service"; import { DependencyFilter, DependentFilter } from "./dto/dependencies.filter"; import { Dependency } from "./dto/dependency"; +import { DependencySort, DependencySortField } from "./dto/dependency-sort"; import { PaginatedDependencies } from "./dto/paginated-dependencies"; import { BlockIndexRefresh } from "./entities/block-index-refresh.entity"; @@ -219,22 +221,19 @@ export class DependenciesService { async getDependents( target: AnyEntity<{ id: string }> | { entityName: string; id: string }, - filter?: DependentFilter & { + passedFilter?: DependentFilter & { rootEntityName?: string; }, paginationArgs?: { offset: number; limit: number }, - options?: { forceRefresh: boolean }, + options?: { forceRefresh: boolean; sort?: DependencySort[] }, ): Promise { await this.refreshViews({ force: options?.forceRefresh }); + const { rootEntityName, ...filter } = passedFilter ?? {}; const entityName = "entityName" in target ? target.entityName : target.constructor.name; - const qb = this.getQueryBuilderWithFilters( - { - ...filter, - targetEntityName: entityName, - targetId: target.id, - }, + const qb = this.getBaseQueryBuilder( + { targetEntityName: entityName, targetId: target.id, rootEntityName: rootEntityName }, paginationArgs, ).join("EntityInfo", (join) => { join.on("idx.rootEntityName", "EntityInfo.entityName").andOn( @@ -243,6 +242,9 @@ export class DependenciesService { ); }); + this.applyFilter(qb, filter); + this.applySort(qb, options?.sort, "dependents"); + const results: Dependency[] = await qb; const countResult: Array<{ count: string | number }> = await this.withCount(qb).select("targetId").groupBy(["targetId", "targetEntityName"]); @@ -253,29 +255,29 @@ export class DependenciesService { async getDependencies( root: AnyEntity<{ id: string }> | { entityName: string; id: string }, - filter?: DependencyFilter & { + passedFilter?: DependencyFilter & { targetEntityName?: string; }, paginationArgs?: { offset: number; limit: number }, - options?: { forceRefresh: boolean }, + options?: { forceRefresh: boolean; sort?: DependencySort[] }, ): Promise { await this.refreshViews({ force: options?.forceRefresh }); + const { targetEntityName, ...filter } = passedFilter ?? {}; const entityName = "entityName" in root ? root.entityName : root.constructor.name; - const qb = this.getQueryBuilderWithFilters( - { - ...filter, - rootEntityName: entityName, - rootId: root.id, + const qb = this.getBaseQueryBuilder({ rootEntityName: entityName, rootId: root.id, targetEntityName: targetEntityName }, paginationArgs).join( + "EntityInfo", + (join) => { + join.on("idx.targetEntityName", "EntityInfo.entityName").andOn( + "EntityInfo.id", + this.entityManager.getKnex("read").raw('"idx"."targetId"::text'), + ); }, - paginationArgs, - ).join("EntityInfo", (join) => { - join.on("idx.targetEntityName", "EntityInfo.entityName").andOn( - "EntityInfo.id", - this.entityManager.getKnex("read").raw('"idx"."targetId"::text'), - ); - }); + ); + + this.applyFilter(qb, filter); + this.applySort(qb, options?.sort, "dependencies"); const results: Dependency[] = await qb; @@ -285,12 +287,8 @@ export class DependenciesService { return new PaginatedDependencies(results, Number(totalCount)); } - private getQueryBuilderWithFilters( - filter: DependentFilter & - DependencyFilter & { - targetEntityName?: string; - rootEntityName?: string; - }, + private getBaseQueryBuilder( + internalFilter: { targetEntityName?: string; rootEntityName?: string; targetId?: string; rootId?: string }, paginationArgs?: { offset: number; limit: number }, ) { const qb = this.entityManager.getKnex("read").select("*").from({ idx: "block_index_dependencies" }); @@ -299,34 +297,125 @@ export class DependenciesService { qb.offset(paginationArgs.offset).limit(paginationArgs.limit); } - if (filter.targetEntityName) { - qb.andWhere({ targetEntityName: filter.targetEntityName }); + if (internalFilter.targetEntityName) { + qb.andWhere({ targetEntityName: internalFilter.targetEntityName }); } - if (filter?.targetGraphqlObjectType) { - qb.andWhere({ targetGraphqlObjectType: filter.targetGraphqlObjectType }); + if (internalFilter.targetId) { + qb.andWhere({ targetId: internalFilter.targetId }); } - if (filter.targetId) { - qb.andWhere({ targetId: filter.targetId }); + if (internalFilter.rootEntityName) { + qb.andWhere({ rootEntityName: internalFilter.rootEntityName }); } + if (internalFilter.rootId) { + qb.andWhere({ rootId: internalFilter.rootId }); + } + + return qb; + } + + private applyFilter(qb: Knex.QueryBuilder, filter?: DependencyFilter | DependentFilter): void { + if (!filter) return; - if (filter.rootEntityName) { - qb.andWhere({ rootEntityName: filter.rootEntityName }); + if ("rootGraphqlObjectType" in filter && filter.rootGraphqlObjectType) { + this.applyStringFilterToKnex(qb, '"idx"."rootGraphqlObjectType"', filter.rootGraphqlObjectType); + } + if ("rootId" in filter && filter.rootId) { + qb.andWhere({ "idx.rootId": filter.rootId }); + } + if ("targetGraphqlObjectType" in filter && filter.targetGraphqlObjectType) { + this.applyStringFilterToKnex(qb, '"idx"."targetGraphqlObjectType"', filter.targetGraphqlObjectType); + } + if ("targetId" in filter && filter.targetId) { + qb.andWhere({ "idx.targetId": filter.targetId }); + } + if (filter.rootColumnName) { + qb.andWhere({ "idx.rootColumnName": filter.rootColumnName }); } - if (filter.rootGraphqlObjectType) { - qb.andWhere({ rootGraphqlObjectType: filter.rootGraphqlObjectType }); + if (filter.name) { + this.applyStringFilterToKnex(qb, '"EntityInfo"."name"', filter.name); } - if (filter.rootId) { - qb.andWhere({ rootId: filter.rootId }); + if (filter.secondaryInformation) { + this.applyStringFilterToKnex(qb, '"EntityInfo"."secondaryInformation"', filter.secondaryInformation); } + if (filter.visible) { + if (filter.visible.equal !== undefined && filter.visible.equal !== null) { + qb.andWhere(this.entityManager.getKnex("read").raw('"idx"."visible" = ?', [filter.visible.equal])); + } + } + if (filter.and?.length) { + for (const subFilter of filter.and) { + qb.andWhere((sub) => { + this.applyFilter(sub, subFilter); + }); + } + } + if (filter.or?.length) { + const orFilters = filter.or; + qb.andWhere((outer) => { + for (const subFilter of orFilters) { + outer.orWhere((sub) => { + this.applyFilter(sub, subFilter); + }); + } + }); + } + } + private applyStringFilterToKnex(qb: Knex.QueryBuilder, column: string, filter: StringFilter): void { + const knex = this.entityManager.getKnex("read"); - if (filter?.rootColumnName) { - qb.andWhere({ rootColumnName: filter.rootColumnName }); + if (filter.contains !== undefined && filter.contains !== null) { + qb.andWhere(knex.raw(`${column} ILIKE ?`, [`%${filter.contains}%`])); + } + if (filter.notContains !== undefined && filter.notContains !== null) { + qb.andWhere(knex.raw(`${column} NOT ILIKE ?`, [`%${filter.notContains}%`])); + } + if (filter.startsWith !== undefined && filter.startsWith !== null) { + qb.andWhere(knex.raw(`${column} ILIKE ?`, [`${filter.startsWith}%`])); + } + if (filter.endsWith !== undefined && filter.endsWith !== null) { + qb.andWhere(knex.raw(`${column} ILIKE ?`, [`%${filter.endsWith}`])); } + if (filter.equal !== undefined && filter.equal !== null) { + qb.andWhere(knex.raw(`${column} = ?`, [filter.equal])); + } + if (filter.notEqual !== undefined && filter.notEqual !== null) { + qb.andWhere(knex.raw(`${column} != ?`, [filter.notEqual])); + } + if (filter.isAnyOf !== undefined && filter.isAnyOf !== null && filter.isAnyOf.length > 0) { + qb.andWhere(knex.raw(`${column} IN (${filter.isAnyOf.map(() => "?").join(", ")})`, filter.isAnyOf)); + } + if (filter.isEmpty) { + qb.andWhere(knex.raw(`(${column} IS NULL OR ${column} = '')`)); + } + if (filter.isNotEmpty) { + qb.andWhere(knex.raw(`(${column} IS NOT NULL AND ${column} != '')`)); + } + } - return qb; + private applySort(qb: Knex.QueryBuilder, sort: DependencySort[] | undefined, context: "dependencies" | "dependents"): void { + if (!sort || sort.length === 0) return; + + for (const sortEntry of sort) { + let column: string; + switch (sortEntry.field) { + case DependencySortField.name: + column = '"EntityInfo"."name"'; + break; + case DependencySortField.secondaryInformation: + column = '"EntityInfo"."secondaryInformation"'; + break; + case DependencySortField.graphqlObjectType: + column = context === "dependencies" ? '"idx"."targetGraphqlObjectType"' : '"idx"."rootGraphqlObjectType"'; + break; + case DependencySortField.visible: + column = '"idx"."visible"'; + break; + } + qb.orderByRaw(`${column} ${sortEntry.direction}`); + } } private withCount(qb: Knex.QueryBuilder) { - return qb.offset(0).clearSelect().count(); + return qb.offset(0).clearSelect().clearOrder().count(); } } diff --git a/packages/api/cms-api/src/dependencies/dependents.resolver.factory.ts b/packages/api/cms-api/src/dependencies/dependents.resolver.factory.ts index aa3267066b8..6893d86f48b 100644 --- a/packages/api/cms-api/src/dependencies/dependents.resolver.factory.ts +++ b/packages/api/cms-api/src/dependencies/dependents.resolver.factory.ts @@ -17,9 +17,9 @@ export class DependentsResolverFactory { @ResolveField(() => PaginatedDependencies) async dependents( @Parent() node: AnyEntity<{ id: string }>, - @Args() { filter, offset, limit, forceRefresh }: DependentsArgs, + @Args() { filter, sort, offset, limit, forceRefresh }: DependentsArgs, ): Promise { - return this.dependenciesService.getDependents(node, filter, { offset, limit }, { forceRefresh }); + return this.dependenciesService.getDependents(node, filter, { offset, limit }, { forceRefresh, sort }); } } diff --git a/packages/api/cms-api/src/dependencies/dto/dependencies.args.ts b/packages/api/cms-api/src/dependencies/dto/dependencies.args.ts index 4ad740088a2..304e33cedf9 100644 --- a/packages/api/cms-api/src/dependencies/dto/dependencies.args.ts +++ b/packages/api/cms-api/src/dependencies/dto/dependencies.args.ts @@ -1,10 +1,11 @@ import { ArgsType, Field } from "@nestjs/graphql"; import { Type } from "class-transformer"; -import { IsBoolean, ValidateNested } from "class-validator"; +import { IsBoolean, IsOptional, ValidateNested } from "class-validator"; import { OffsetBasedPaginationArgs } from "../../common/pagination/offset-based.args"; import { IsUndefinable } from "../../common/validators/is-undefinable"; import { DependencyFilter, DependentFilter } from "./dependencies.filter"; +import { DependencySort } from "./dependency-sort"; @ArgsType() export class DependenciesArgs extends OffsetBasedPaginationArgs { @@ -14,6 +15,12 @@ export class DependenciesArgs extends OffsetBasedPaginationArgs { @IsUndefinable() filter?: DependencyFilter; + @Field(() => [DependencySort], { nullable: true }) + @ValidateNested({ each: true }) + @Type(() => DependencySort) + @IsOptional() + sort?: DependencySort[]; + @Field(() => Boolean, { defaultValue: false }) @IsBoolean() forceRefresh: boolean; @@ -27,6 +34,12 @@ export class DependentsArgs extends OffsetBasedPaginationArgs { @IsUndefinable() filter?: DependentFilter; + @Field(() => [DependencySort], { nullable: true }) + @ValidateNested({ each: true }) + @Type(() => DependencySort) + @IsOptional() + sort?: DependencySort[]; + @Field(() => Boolean, { defaultValue: false }) @IsBoolean() forceRefresh: boolean; diff --git a/packages/api/cms-api/src/dependencies/dto/dependencies.filter.ts b/packages/api/cms-api/src/dependencies/dto/dependencies.filter.ts index 0c63807e37b..840f6cd9312 100644 --- a/packages/api/cms-api/src/dependencies/dto/dependencies.filter.ts +++ b/packages/api/cms-api/src/dependencies/dto/dependencies.filter.ts @@ -1,14 +1,18 @@ import { Field, InputType } from "@nestjs/graphql"; -import { IsString } from "class-validator"; +import { Type } from "class-transformer"; +import { IsOptional, IsString, ValidateNested } from "class-validator"; +import { BooleanFilter } from "../../common/filter/boolean.filter"; +import { StringFilter } from "../../common/filter/string.filter"; import { IsUndefinable } from "../../common/validators/is-undefinable"; @InputType() export class DependencyFilter { - @Field({ nullable: true }) - @IsString() - @IsUndefinable() - targetGraphqlObjectType?: string; + @Field(() => StringFilter, { nullable: true }) + @ValidateNested() + @Type(() => StringFilter) + @IsOptional() + targetGraphqlObjectType?: StringFilter; @Field({ nullable: true }) @IsString() @@ -19,14 +23,45 @@ export class DependencyFilter { @IsString() @IsUndefinable() rootColumnName?: string; + + @Field(() => StringFilter, { nullable: true }) + @ValidateNested() + @Type(() => StringFilter) + @IsOptional() + name?: StringFilter; + + @Field(() => StringFilter, { nullable: true }) + @ValidateNested() + @Type(() => StringFilter) + @IsOptional() + secondaryInformation?: StringFilter; + + @Field(() => BooleanFilter, { nullable: true }) + @ValidateNested() + @Type(() => BooleanFilter) + @IsOptional() + visible?: BooleanFilter; + + @Field(() => [DependencyFilter], { nullable: true }) + @ValidateNested({ each: true }) + @Type(() => DependencyFilter) + @IsOptional() + and?: DependencyFilter[]; + + @Field(() => [DependencyFilter], { nullable: true }) + @ValidateNested({ each: true }) + @Type(() => DependencyFilter) + @IsOptional() + or?: DependencyFilter[]; } @InputType() export class DependentFilter { - @Field({ nullable: true }) - @IsString() - @IsUndefinable() - rootGraphqlObjectType?: string; + @Field(() => StringFilter, { nullable: true }) + @ValidateNested() + @Type(() => StringFilter) + @IsOptional() + rootGraphqlObjectType?: StringFilter; @Field({ nullable: true }) @IsString() @@ -37,4 +72,34 @@ export class DependentFilter { @IsString() @IsUndefinable() rootColumnName?: string; + + @Field(() => StringFilter, { nullable: true }) + @ValidateNested() + @Type(() => StringFilter) + @IsOptional() + name?: StringFilter; + + @Field(() => StringFilter, { nullable: true }) + @ValidateNested() + @Type(() => StringFilter) + @IsOptional() + secondaryInformation?: StringFilter; + + @Field(() => BooleanFilter, { nullable: true }) + @ValidateNested() + @Type(() => BooleanFilter) + @IsOptional() + visible?: BooleanFilter; + + @Field(() => [DependentFilter], { nullable: true }) + @ValidateNested({ each: true }) + @Type(() => DependentFilter) + @IsOptional() + and?: DependentFilter[]; + + @Field(() => [DependentFilter], { nullable: true }) + @ValidateNested({ each: true }) + @Type(() => DependentFilter) + @IsOptional() + or?: DependentFilter[]; } diff --git a/packages/api/cms-api/src/dependencies/dto/dependency-sort.ts b/packages/api/cms-api/src/dependencies/dto/dependency-sort.ts new file mode 100644 index 00000000000..dc0edbb083c --- /dev/null +++ b/packages/api/cms-api/src/dependencies/dto/dependency-sort.ts @@ -0,0 +1,25 @@ +import { Field, InputType, registerEnumType } from "@nestjs/graphql"; +import { IsEnum } from "class-validator"; + +import { SortDirection } from "../../common/sorting/sort-direction.enum"; + +export enum DependencySortField { + name = "name", + secondaryInformation = "secondaryInformation", + graphqlObjectType = "graphqlObjectType", + visible = "visible", +} +registerEnumType(DependencySortField, { + name: "DependencySortField", +}); + +@InputType() +export class DependencySort { + @Field(() => DependencySortField) + @IsEnum(DependencySortField) + field: DependencySortField; + + @Field(() => SortDirection, { defaultValue: SortDirection.ASC }) + @IsEnum(SortDirection) + direction: SortDirection = SortDirection.ASC; +}