feat(dashboards): query dashboards by/on project (#5704)

This commit is contained in:
Chuck Driesler
2025-10-08 11:07:28 +01:00
committed by GitHub
parent 86dc52f93d
commit d0b528c202
6 changed files with 126 additions and 15 deletions
@@ -6,6 +6,28 @@ extend type Mutation {
dashboardMutations: DashboardMutations! @hasServerRole(role: SERVER_GUEST)
}
type DashboardMutations {
create(workspace: WorkspaceIdentifier!, input: DashboardCreateInput!): Dashboard!
delete(id: String!): Boolean!
update(input: DashboardUpdateInput!): Dashboard!
}
extend type Workspace {
dashboards(
limit: Int! = 50
cursor: String
filter: WorkspaceDashboardsFilter
): DashboardCollection!
}
extend type Project {
dashboards(
limit: Int! = 50
cursor: String
filter: ProjectDashboardsFilter
): DashboardCollection!
}
type Dashboard {
id: String!
name: String!
@@ -25,14 +47,13 @@ type DashboardCollection {
totalCount: Int!
}
extend type Workspace {
dashboards(limit: Int! = 50, cursor: String): DashboardCollection!
input WorkspaceDashboardsFilter {
projectIds: [String!]
search: String
}
type DashboardMutations {
create(workspace: WorkspaceIdentifier!, input: DashboardCreateInput!): Dashboard!
delete(id: String!): Boolean!
update(input: DashboardUpdateInput!): Dashboard!
input ProjectDashboardsFilter {
search: String
}
input DashboardCreateInput {
@@ -2656,6 +2656,7 @@ export type Project = {
commentThreads: ProjectCommentCollection;
createdAt: Scalars['DateTime']['output'];
dashboardTokens: DashboardTokenCollection;
dashboards: DashboardCollection;
description?: Maybe<Scalars['String']['output']>;
/** Public project-level configuration for embedded viewer */
embedOptions: ProjectEmbedOptions;
@@ -2774,6 +2775,13 @@ export type ProjectDashboardTokensArgs = {
};
export type ProjectDashboardsArgs = {
cursor?: InputMaybe<Scalars['String']['input']>;
filter?: InputMaybe<ProjectDashboardsFilter>;
limit?: Scalars['Int']['input'];
};
export type ProjectEmbedTokensArgs = {
cursor?: InputMaybe<Scalars['String']['input']>;
limit?: InputMaybe<Scalars['Int']['input']>;
@@ -3091,6 +3099,10 @@ export type ProjectCreateInput = {
visibility?: InputMaybe<ProjectVisibility>;
};
export type ProjectDashboardsFilter = {
search?: InputMaybe<Scalars['String']['input']>;
};
export type ProjectEmbedOptions = {
__typename?: 'ProjectEmbedOptions';
hideSpeckleBranding: Scalars['Boolean']['output'];
@@ -5679,6 +5691,7 @@ export type WorkspaceAutomateFunctionsArgs = {
export type WorkspaceDashboardsArgs = {
cursor?: InputMaybe<Scalars['String']['input']>;
filter?: InputMaybe<WorkspaceDashboardsFilter>;
limit?: Scalars['Int']['input'];
};
@@ -5777,6 +5790,11 @@ export type WorkspaceCreationStateInput = {
workspaceId: Scalars['ID']['input'];
};
export type WorkspaceDashboardsFilter = {
projectIds?: InputMaybe<Array<Scalars['String']['input']>>;
search?: InputMaybe<Scalars['String']['input']>;
};
export type WorkspaceDismissInput = {
workspaceId: Scalars['ID']['input'];
};
@@ -6623,6 +6641,7 @@ export type ResolversTypes = {
ProjectCommentsUpdatedMessage: ResolverTypeWrapper<Omit<ProjectCommentsUpdatedMessage, 'comment'> & { comment?: Maybe<ResolversTypes['Comment']> }>;
ProjectCommentsUpdatedMessageType: ProjectCommentsUpdatedMessageType;
ProjectCreateInput: ProjectCreateInput;
ProjectDashboardsFilter: ProjectDashboardsFilter;
ProjectEmbedOptions: ResolverTypeWrapper<ProjectEmbedOptions>;
ProjectFileImportUpdatedMessage: ResolverTypeWrapper<Omit<ProjectFileImportUpdatedMessage, 'upload'> & { upload: ResolversTypes['FileUpload'] }>;
ProjectFileImportUpdatedMessageType: ProjectFileImportUpdatedMessageType;
@@ -6776,6 +6795,7 @@ export type ResolversTypes = {
WorkspaceCreateInput: WorkspaceCreateInput;
WorkspaceCreationState: ResolverTypeWrapper<WorkspaceCreationState>;
WorkspaceCreationStateInput: WorkspaceCreationStateInput;
WorkspaceDashboardsFilter: WorkspaceDashboardsFilter;
WorkspaceDismissInput: WorkspaceDismissInput;
WorkspaceDomain: ResolverTypeWrapper<WorkspaceDomain>;
WorkspaceDomainDeleteInput: WorkspaceDomainDeleteInput;
@@ -7033,6 +7053,7 @@ export type ResolversParentTypes = {
ProjectCommentsFilter: ProjectCommentsFilter;
ProjectCommentsUpdatedMessage: Omit<ProjectCommentsUpdatedMessage, 'comment'> & { comment?: Maybe<ResolversParentTypes['Comment']> };
ProjectCreateInput: ProjectCreateInput;
ProjectDashboardsFilter: ProjectDashboardsFilter;
ProjectEmbedOptions: ProjectEmbedOptions;
ProjectFileImportUpdatedMessage: Omit<ProjectFileImportUpdatedMessage, 'upload'> & { upload: ResolversParentTypes['FileUpload'] };
ProjectInviteCreateInput: ProjectInviteCreateInput;
@@ -7167,6 +7188,7 @@ export type ResolversParentTypes = {
WorkspaceCreateInput: WorkspaceCreateInput;
WorkspaceCreationState: WorkspaceCreationState;
WorkspaceCreationStateInput: WorkspaceCreationStateInput;
WorkspaceDashboardsFilter: WorkspaceDashboardsFilter;
WorkspaceDismissInput: WorkspaceDismissInput;
WorkspaceDomain: WorkspaceDomain;
WorkspaceDomainDeleteInput: WorkspaceDomainDeleteInput;
@@ -8277,6 +8299,7 @@ export type ProjectResolvers<ContextType = GraphQLContext, ParentType extends Re
commentThreads?: Resolver<ResolversTypes['ProjectCommentCollection'], ParentType, ContextType, Partial<ProjectCommentThreadsArgs>>;
createdAt?: Resolver<ResolversTypes['DateTime'], ParentType, ContextType>;
dashboardTokens?: Resolver<ResolversTypes['DashboardTokenCollection'], ParentType, ContextType, Partial<ProjectDashboardTokensArgs>>;
dashboards?: Resolver<ResolversTypes['DashboardCollection'], ParentType, ContextType, RequireFields<ProjectDashboardsArgs, 'limit'>>;
description?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
embedOptions?: Resolver<ResolversTypes['ProjectEmbedOptions'], ParentType, ContextType>;
embedTokens?: Resolver<ResolversTypes['EmbedTokenCollection'], ParentType, ContextType, Partial<ProjectEmbedTokensArgs>>;
@@ -14,9 +14,17 @@ export type UpsertDashboardRecord = <T extends Exact<Dashboard, T>>(
export type ListDashboardRecords = (args: {
workspaceId: string
filter?: {
projectIds: string[] | null
search: string | null
updatedBefore: string | null
limit: number | null
}
}) => Promise<Dashboard[]>
export type CountDashboardRecords = (args: { workspaceId: string }) => Promise<number>
export type CountDashboardRecords = (args: {
workspaceId: string
filter?: {
projectIds: string[] | null
search: string | null
}
}) => Promise<number>
@@ -82,7 +82,35 @@ const resolvers: Resolvers = {
workspaceId: parent.id,
filter: {
limit: args.limit,
cursor: args.cursor ?? null
cursor: args.cursor ?? null,
projectIds: args.filter?.projectIds ?? [],
search: args.filter?.search ?? null
}
})
}
},
Project: {
dashboards: async (parent, args, context) => {
const authResult = await context.authPolicies.workspace.canListDashboards({
userId: context.userId,
workspaceId: parent.id
})
throwIfAuthNotOk(authResult)
if (!parent.workspaceId) {
throw new WorkspaceNotFoundError()
}
return await getPaginatedDashboardsFactory({
listDashboards: listDashboardsFactory({ db }),
countDashboards: countDashboardsFactory({ db })
})({
workspaceId: parent.workspaceId,
filter: {
limit: args.limit,
cursor: args.cursor ?? null,
projectIds: [parent.id],
search: args.filter?.search ?? null
}
})
}
@@ -61,15 +61,33 @@ export const listDashboardsFactory =
q.andWhere(Dashboards.col.updatedAt, '<', filter.updatedBefore)
}
if (filter?.projectIds?.length) {
q.andWhereRaw('?? && ?', [Dashboards.col.projectIds, filter.projectIds])
}
if (filter?.search) {
q.andWhereILike(Dashboards.col.name, filter.search)
}
return await q
}
export const countDashboardsFactory =
(deps: { db: Knex }): CountDashboardRecords =>
async ({ workspaceId }) => {
const [{ count }] = await tables
.dashboards(deps.db)
.where(Dashboards.col.workspaceId, workspaceId)
.count()
async ({ workspaceId, filter = {} }) => {
const { projectIds, search } = filter
const q = tables.dashboards(deps.db).where(Dashboards.col.workspaceId, workspaceId)
if (projectIds?.length) {
q.andWhereRaw('?? && ?', [Dashboards.col.projectIds, projectIds])
}
if (search) {
q.andWhereILike(Dashboards.col.name, search)
}
const [{ count }] = await q.count()
return Number.parseInt(count as string)
}
@@ -19,6 +19,7 @@ import type {
StoreTokenResourceAccessDefinitions
} from '@/modules/core/domain/tokens/operations'
import { TokenResourceIdentifierType } from '@/modules/core/domain/tokens/types'
import { clamp } from 'lodash-es'
export type CreateDashboard = (params: {
name: string
@@ -150,6 +151,8 @@ export type GetPaginatedDashboards = (params: {
filter?: {
limit: number | null
cursor: string | null
projectIds: string[] | null
search: string | null
}
}) => Promise<Collection<Dashboard>>
@@ -160,16 +163,26 @@ export const getPaginatedDashboardsFactory =
}): GetPaginatedDashboards =>
async ({ workspaceId, filter }) => {
const cursor = filter?.cursor ? decodeIsoDateCursor(filter.cursor) : null
const projectIds = filter?.projectIds ?? []
const search = filter?.search ?? null
const [items, totalCount] = await Promise.all([
deps.listDashboards({
workspaceId,
filter: {
updatedBefore: cursor,
limit: filter?.limit ?? null
limit: clamp(filter?.limit ?? 50, 1, 200),
projectIds,
search
}
}),
deps.countDashboards({ workspaceId })
deps.countDashboards({
workspaceId,
filter: {
projectIds,
search
}
})
])
const lastItem = items.at(-1)