mirror of
https://github.com/graphql-hive/console
synced 2026-05-24 09:38:26 +00:00
Feature/insights filters [CONSOLE-1607] (#7662)
This commit is contained in:
parent
fd1aef16e2
commit
cefdb600bf
85 changed files with 9020 additions and 1196 deletions
174
integration-tests/testkit/saved-filters.ts
Normal file
174
integration-tests/testkit/saved-filters.ts
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
import { graphql } from './gql';
|
||||
|
||||
export const GetSavedFilterQuery = graphql(`
|
||||
query GetSavedFilter($selector: TargetSelectorInput!, $id: ID!) {
|
||||
target(reference: { bySelector: $selector }) {
|
||||
id
|
||||
savedFilter(id: $id) {
|
||||
id
|
||||
name
|
||||
description
|
||||
filters {
|
||||
operationHashes
|
||||
clientFilters {
|
||||
name
|
||||
versions
|
||||
}
|
||||
dateRange {
|
||||
from
|
||||
to
|
||||
}
|
||||
}
|
||||
visibility
|
||||
viewsCount
|
||||
createdAt
|
||||
updatedAt
|
||||
createdBy {
|
||||
id
|
||||
displayName
|
||||
}
|
||||
viewerCanUpdate
|
||||
viewerCanDelete
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
export const GetSavedFiltersQuery = graphql(`
|
||||
query GetSavedFilters(
|
||||
$selector: TargetSelectorInput!
|
||||
$first: Int!
|
||||
$after: String
|
||||
$visibility: SavedFilterVisibilityType
|
||||
$search: String
|
||||
) {
|
||||
target(reference: { bySelector: $selector }) {
|
||||
id
|
||||
savedFilters(first: $first, after: $after, visibility: $visibility, search: $search) {
|
||||
edges {
|
||||
cursor
|
||||
node {
|
||||
id
|
||||
name
|
||||
description
|
||||
visibility
|
||||
viewsCount
|
||||
createdAt
|
||||
createdBy {
|
||||
id
|
||||
displayName
|
||||
}
|
||||
viewerCanUpdate
|
||||
viewerCanDelete
|
||||
}
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
viewerCanCreateSavedFilter
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
export const CreateSavedFilterMutation = graphql(`
|
||||
mutation CreateSavedFilter($input: CreateSavedFilterInput!) {
|
||||
createSavedFilter(input: $input) {
|
||||
error {
|
||||
message
|
||||
}
|
||||
ok {
|
||||
savedFilter {
|
||||
id
|
||||
name
|
||||
description
|
||||
filters {
|
||||
operationHashes
|
||||
clientFilters {
|
||||
name
|
||||
versions
|
||||
}
|
||||
dateRange {
|
||||
from
|
||||
to
|
||||
}
|
||||
}
|
||||
visibility
|
||||
viewsCount
|
||||
createdAt
|
||||
createdBy {
|
||||
id
|
||||
}
|
||||
viewerCanUpdate
|
||||
viewerCanDelete
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
export const UpdateSavedFilterMutation = graphql(`
|
||||
mutation UpdateSavedFilter($input: UpdateSavedFilterInput!) {
|
||||
updateSavedFilter(input: $input) {
|
||||
error {
|
||||
message
|
||||
}
|
||||
ok {
|
||||
savedFilter {
|
||||
id
|
||||
name
|
||||
description
|
||||
filters {
|
||||
operationHashes
|
||||
clientFilters {
|
||||
name
|
||||
versions
|
||||
}
|
||||
dateRange {
|
||||
from
|
||||
to
|
||||
}
|
||||
}
|
||||
visibility
|
||||
viewsCount
|
||||
updatedAt
|
||||
updatedBy {
|
||||
id
|
||||
}
|
||||
viewerCanUpdate
|
||||
viewerCanDelete
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
export const DeleteSavedFilterMutation = graphql(`
|
||||
mutation DeleteSavedFilter($input: DeleteSavedFilterInput!) {
|
||||
deleteSavedFilter(input: $input) {
|
||||
error {
|
||||
message
|
||||
}
|
||||
ok {
|
||||
deletedId
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
export const TrackSavedFilterViewMutation = graphql(`
|
||||
mutation TrackSavedFilterView($input: TrackSavedFilterViewInput!) {
|
||||
trackSavedFilterView(input: $input) {
|
||||
error {
|
||||
message
|
||||
}
|
||||
ok {
|
||||
savedFilter {
|
||||
id
|
||||
viewsCount
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
|
@ -58,6 +58,14 @@ import {
|
|||
TargetAccessScope,
|
||||
} from './gql/graphql';
|
||||
import { execute } from './graphql';
|
||||
import {
|
||||
CreateSavedFilterMutation,
|
||||
DeleteSavedFilterMutation,
|
||||
GetSavedFilterQuery,
|
||||
GetSavedFiltersQuery,
|
||||
TrackSavedFilterViewMutation,
|
||||
UpdateSavedFilterMutation,
|
||||
} from './saved-filters';
|
||||
import { UpdateSchemaPolicyForOrganization, UpdateSchemaPolicyForProject } from './schema-policy';
|
||||
import { collect, CollectedOperation, legacyCollect } from './usage';
|
||||
import { generateUnique, getServiceHost } from './utils';
|
||||
|
|
@ -490,6 +498,187 @@ export function initSeed() {
|
|||
|
||||
return result.updateOperationInDocumentCollection;
|
||||
},
|
||||
async getSavedFilter({
|
||||
filterId,
|
||||
token = ownerToken,
|
||||
}: {
|
||||
filterId: string;
|
||||
token?: string;
|
||||
}) {
|
||||
const result = await execute({
|
||||
document: GetSavedFilterQuery,
|
||||
variables: {
|
||||
id: filterId,
|
||||
selector: {
|
||||
organizationSlug: organization.slug,
|
||||
projectSlug: project.slug,
|
||||
targetSlug: target.slug,
|
||||
},
|
||||
},
|
||||
authToken: token,
|
||||
}).then(r => r.expectNoGraphQLErrors());
|
||||
|
||||
return result.target?.savedFilter;
|
||||
},
|
||||
async getSavedFilters({
|
||||
first = 20,
|
||||
after,
|
||||
visibility,
|
||||
search,
|
||||
token = ownerToken,
|
||||
}: {
|
||||
first?: number;
|
||||
after?: string;
|
||||
visibility?: GraphQLSchema.SavedFilterVisibilityType;
|
||||
search?: string;
|
||||
token?: string;
|
||||
}) {
|
||||
const result = await execute({
|
||||
document: GetSavedFiltersQuery,
|
||||
variables: {
|
||||
first,
|
||||
after,
|
||||
visibility,
|
||||
search,
|
||||
selector: {
|
||||
organizationSlug: organization.slug,
|
||||
projectSlug: project.slug,
|
||||
targetSlug: target.slug,
|
||||
},
|
||||
},
|
||||
authToken: token,
|
||||
}).then(r => r.expectNoGraphQLErrors());
|
||||
|
||||
return {
|
||||
savedFilters: result.target?.savedFilters,
|
||||
viewerCanCreateSavedFilter: result.target?.viewerCanCreateSavedFilter,
|
||||
};
|
||||
},
|
||||
async createSavedFilter({
|
||||
name,
|
||||
description,
|
||||
visibility,
|
||||
insightsFilter,
|
||||
token = ownerToken,
|
||||
}: {
|
||||
name: string;
|
||||
description?: string;
|
||||
visibility: GraphQLSchema.SavedFilterVisibilityType;
|
||||
insightsFilter?: GraphQLSchema.InsightsFilterConfigurationInput;
|
||||
token?: string;
|
||||
}) {
|
||||
const result = await execute({
|
||||
document: CreateSavedFilterMutation,
|
||||
variables: {
|
||||
input: {
|
||||
target: {
|
||||
bySelector: {
|
||||
organizationSlug: organization.slug,
|
||||
projectSlug: project.slug,
|
||||
targetSlug: target.slug,
|
||||
},
|
||||
},
|
||||
name,
|
||||
description,
|
||||
visibility,
|
||||
insightsFilter,
|
||||
},
|
||||
},
|
||||
authToken: token,
|
||||
}).then(r => r.expectNoGraphQLErrors());
|
||||
|
||||
return result.createSavedFilter;
|
||||
},
|
||||
async updateSavedFilter({
|
||||
filterId,
|
||||
name,
|
||||
description,
|
||||
visibility,
|
||||
insightsFilter,
|
||||
token = ownerToken,
|
||||
}: {
|
||||
filterId: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
visibility?: GraphQLSchema.SavedFilterVisibilityType;
|
||||
insightsFilter?: GraphQLSchema.InsightsFilterConfigurationInput;
|
||||
token?: string;
|
||||
}) {
|
||||
const result = await execute({
|
||||
document: UpdateSavedFilterMutation,
|
||||
variables: {
|
||||
input: {
|
||||
target: {
|
||||
bySelector: {
|
||||
organizationSlug: organization.slug,
|
||||
projectSlug: project.slug,
|
||||
targetSlug: target.slug,
|
||||
},
|
||||
},
|
||||
id: filterId,
|
||||
name,
|
||||
description,
|
||||
visibility,
|
||||
insightsFilter,
|
||||
},
|
||||
},
|
||||
authToken: token,
|
||||
}).then(r => r.expectNoGraphQLErrors());
|
||||
|
||||
return result.updateSavedFilter;
|
||||
},
|
||||
async deleteSavedFilter({
|
||||
filterId,
|
||||
token = ownerToken,
|
||||
}: {
|
||||
filterId: string;
|
||||
token?: string;
|
||||
}) {
|
||||
const result = await execute({
|
||||
document: DeleteSavedFilterMutation,
|
||||
variables: {
|
||||
input: {
|
||||
target: {
|
||||
bySelector: {
|
||||
organizationSlug: organization.slug,
|
||||
projectSlug: project.slug,
|
||||
targetSlug: target.slug,
|
||||
},
|
||||
},
|
||||
id: filterId,
|
||||
},
|
||||
},
|
||||
authToken: token,
|
||||
}).then(r => r.expectNoGraphQLErrors());
|
||||
|
||||
return result.deleteSavedFilter;
|
||||
},
|
||||
async trackSavedFilterView({
|
||||
filterId,
|
||||
token = ownerToken,
|
||||
}: {
|
||||
filterId: string;
|
||||
token?: string;
|
||||
}) {
|
||||
const result = await execute({
|
||||
document: TrackSavedFilterViewMutation,
|
||||
variables: {
|
||||
input: {
|
||||
target: {
|
||||
bySelector: {
|
||||
organizationSlug: organization.slug,
|
||||
projectSlug: project.slug,
|
||||
targetSlug: target.slug,
|
||||
},
|
||||
},
|
||||
id: filterId,
|
||||
},
|
||||
},
|
||||
authToken: token,
|
||||
}).then(r => r.expectNoGraphQLErrors());
|
||||
|
||||
return result.trackSavedFilterView;
|
||||
},
|
||||
async addAlert(
|
||||
input: {
|
||||
token?: string;
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ test.concurrent('owner of an organization should have all scopes', async ({ expe
|
|||
project:delete,
|
||||
project:modifySettings,
|
||||
projectAccessToken:modify,
|
||||
sharedSavedFilter:modify,
|
||||
schemaLinting:modifyProjectRules,
|
||||
target:create,
|
||||
alert:modify,
|
||||
|
|
|
|||
434
integration-tests/tests/api/saved-filters/saved-filters.spec.ts
Normal file
434
integration-tests/tests/api/saved-filters/saved-filters.spec.ts
Normal file
|
|
@ -0,0 +1,434 @@
|
|||
/* eslint-disable @typescript-eslint/no-non-null-asserted-optional-chain */
|
||||
import { ProjectType, SavedFilterVisibilityType } from 'testkit/gql/graphql';
|
||||
import { initSeed } from '../../../testkit/seed';
|
||||
|
||||
describe('Saved Filters', () => {
|
||||
describe('CRUD', () => {
|
||||
test.concurrent('create, update and delete a saved filter', async ({ expect }) => {
|
||||
const { createOrg } = await initSeed().createOwner();
|
||||
const { createProject } = await createOrg();
|
||||
const { createSavedFilter, updateSavedFilter, deleteSavedFilter, getSavedFilter } =
|
||||
await createProject(ProjectType.Single);
|
||||
|
||||
// Create a filter
|
||||
const createResult = await createSavedFilter({
|
||||
name: 'My Filter',
|
||||
description: 'Filter for high-traffic operations',
|
||||
|
||||
visibility: SavedFilterVisibilityType.Private,
|
||||
insightsFilter: {
|
||||
operationHashes: ['op1', 'op2'],
|
||||
clientFilters: [{ name: 'Hive CLI', versions: ['0.12.1', '0.12.3'] }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(createResult.error).toBeNull();
|
||||
expect(createResult.ok?.savedFilter.id).toBeDefined();
|
||||
expect(createResult.ok?.savedFilter.name).toBe('My Filter');
|
||||
expect(createResult.ok?.savedFilter.description).toBe('Filter for high-traffic operations');
|
||||
|
||||
expect(createResult.ok?.savedFilter.visibility).toBe('PRIVATE');
|
||||
expect(createResult.ok?.savedFilter.viewsCount).toBe(0);
|
||||
expect(createResult.ok?.savedFilter.filters.operationHashes).toEqual(['op1', 'op2']);
|
||||
expect(createResult.ok?.savedFilter.filters.clientFilters).toEqual([
|
||||
{ name: 'Hive CLI', versions: ['0.12.1', '0.12.3'] },
|
||||
]);
|
||||
expect(createResult.ok?.savedFilter.viewerCanUpdate).toBe(true);
|
||||
expect(createResult.ok?.savedFilter.viewerCanDelete).toBe(true);
|
||||
|
||||
const filterId = createResult.ok?.savedFilter.id!;
|
||||
|
||||
// Verify filter can be fetched
|
||||
const fetchedFilter = await getSavedFilter({ filterId });
|
||||
expect(fetchedFilter?.id).toBe(filterId);
|
||||
expect(fetchedFilter?.name).toBe('My Filter');
|
||||
|
||||
// Update the filter
|
||||
const updateResult = await updateSavedFilter({
|
||||
filterId,
|
||||
name: 'Updated Filter Name',
|
||||
description: 'Updated description',
|
||||
visibility: SavedFilterVisibilityType.Shared,
|
||||
insightsFilter: {
|
||||
operationHashes: ['op3'],
|
||||
clientFilters: [{ name: 'Gateway' }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(updateResult.error).toBeNull();
|
||||
expect(updateResult.ok?.savedFilter.id).toBe(filterId);
|
||||
expect(updateResult.ok?.savedFilter.name).toBe('Updated Filter Name');
|
||||
expect(updateResult.ok?.savedFilter.description).toBe('Updated description');
|
||||
expect(updateResult.ok?.savedFilter.visibility).toBe('SHARED');
|
||||
expect(updateResult.ok?.savedFilter.filters.operationHashes).toEqual(['op3']);
|
||||
expect(updateResult.ok?.savedFilter.filters.clientFilters).toEqual([
|
||||
{ name: 'Gateway', versions: null },
|
||||
]);
|
||||
expect(updateResult.ok?.savedFilter.updatedBy?.id).toBeDefined();
|
||||
|
||||
// Delete the filter
|
||||
const deleteResult = await deleteSavedFilter({ filterId });
|
||||
expect(deleteResult.error).toBeNull();
|
||||
expect(deleteResult.ok?.deletedId).toBe(filterId);
|
||||
|
||||
// Verify filter is deleted
|
||||
const deletedFilter = await getSavedFilter({ filterId });
|
||||
expect(deletedFilter).toBeNull();
|
||||
});
|
||||
|
||||
test.concurrent('list saved filters with pagination and filtering', async ({ expect }) => {
|
||||
const { createOrg } = await initSeed().createOwner();
|
||||
const { createProject } = await createOrg();
|
||||
const { createSavedFilter, getSavedFilters } = await createProject(ProjectType.Single);
|
||||
|
||||
// Create multiple filters
|
||||
await createSavedFilter({
|
||||
name: 'Private Filter 1',
|
||||
|
||||
visibility: SavedFilterVisibilityType.Private,
|
||||
insightsFilter: { operationHashes: ['op1'] },
|
||||
});
|
||||
|
||||
await createSavedFilter({
|
||||
name: 'Private Filter 2',
|
||||
|
||||
visibility: SavedFilterVisibilityType.Private,
|
||||
insightsFilter: { operationHashes: ['op2'] },
|
||||
});
|
||||
|
||||
await createSavedFilter({
|
||||
name: 'Shared Filter 1',
|
||||
|
||||
visibility: SavedFilterVisibilityType.Shared,
|
||||
insightsFilter: { operationHashes: ['op3'] },
|
||||
});
|
||||
|
||||
// List all filters (private + shared for owner)
|
||||
const allFilters = await getSavedFilters({
|
||||
first: 10,
|
||||
});
|
||||
|
||||
expect(allFilters.savedFilters?.edges.length).toBe(3);
|
||||
expect(allFilters.viewerCanCreateSavedFilter).toBe(true);
|
||||
|
||||
// List only private filters
|
||||
const privateFilters = await getSavedFilters({
|
||||
first: 10,
|
||||
visibility: SavedFilterVisibilityType.Private,
|
||||
});
|
||||
|
||||
expect(privateFilters.savedFilters?.edges.length).toBe(2);
|
||||
expect(privateFilters.savedFilters?.edges.every(e => e.node.visibility === 'PRIVATE')).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
// List only shared filters
|
||||
const sharedFilters = await getSavedFilters({
|
||||
first: 10,
|
||||
visibility: SavedFilterVisibilityType.Shared,
|
||||
});
|
||||
|
||||
expect(sharedFilters.savedFilters?.edges.length).toBe(1);
|
||||
expect(sharedFilters.savedFilters?.edges[0].node.visibility).toBe('SHARED');
|
||||
|
||||
// Search by name
|
||||
const searchResults = await getSavedFilters({
|
||||
first: 10,
|
||||
search: 'Private',
|
||||
});
|
||||
|
||||
expect(searchResults.savedFilters?.edges.length).toBe(2);
|
||||
});
|
||||
|
||||
test.concurrent('track filter views', async ({ expect }) => {
|
||||
const { createOrg } = await initSeed().createOwner();
|
||||
const { createProject } = await createOrg();
|
||||
const { createSavedFilter, trackSavedFilterView, getSavedFilter } = await createProject(
|
||||
ProjectType.Single,
|
||||
);
|
||||
|
||||
// Create a filter
|
||||
const createResult = await createSavedFilter({
|
||||
name: 'Trackable Filter',
|
||||
|
||||
visibility: SavedFilterVisibilityType.Shared,
|
||||
insightsFilter: { operationHashes: ['op1'] },
|
||||
});
|
||||
|
||||
const filterId = createResult.ok?.savedFilter.id!;
|
||||
expect(createResult.ok?.savedFilter.viewsCount).toBe(0);
|
||||
|
||||
// Track views
|
||||
const trackResult1 = await trackSavedFilterView({ filterId });
|
||||
expect(trackResult1.error).toBeNull();
|
||||
expect(trackResult1.ok?.savedFilter.viewsCount).toBe(1);
|
||||
|
||||
const trackResult2 = await trackSavedFilterView({ filterId });
|
||||
expect(trackResult2.ok?.savedFilter.viewsCount).toBe(2);
|
||||
|
||||
// Verify count persisted
|
||||
const filter = await getSavedFilter({ filterId });
|
||||
expect(filter?.viewsCount).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Visibility and Permissions', () => {
|
||||
test.concurrent('private filters should only be visible to the creator', async ({ expect }) => {
|
||||
const { createOrg, ownerToken } = await initSeed().createOwner();
|
||||
const { createProject, inviteAndJoinMember } = await createOrg();
|
||||
const { createSavedFilter, getSavedFilter, getSavedFilters } = await createProject(
|
||||
ProjectType.Single,
|
||||
);
|
||||
|
||||
// Create a private filter as owner
|
||||
const createResult = await createSavedFilter({
|
||||
name: 'Owner Private Filter',
|
||||
|
||||
visibility: SavedFilterVisibilityType.Private,
|
||||
insightsFilter: { operationHashes: ['op1'] },
|
||||
});
|
||||
|
||||
const filterId = createResult.ok?.savedFilter.id!;
|
||||
|
||||
// Invite and join a member
|
||||
const { memberToken } = await inviteAndJoinMember();
|
||||
|
||||
// Owner can see the filter
|
||||
const ownerFilter = await getSavedFilter({ filterId, token: ownerToken });
|
||||
expect(ownerFilter?.id).toBe(filterId);
|
||||
|
||||
// Member cannot see private filter
|
||||
const memberFilter = await getSavedFilter({ filterId, token: memberToken });
|
||||
expect(memberFilter).toBeNull();
|
||||
|
||||
// Member cannot see private filter in list
|
||||
const memberFilters = await getSavedFilters({
|
||||
first: 10,
|
||||
token: memberToken,
|
||||
});
|
||||
expect(memberFilters.savedFilters?.edges.length).toBe(0);
|
||||
});
|
||||
|
||||
test.concurrent(
|
||||
'shared filters should be visible to all project members',
|
||||
async ({ expect }) => {
|
||||
const { createOrg } = await initSeed().createOwner();
|
||||
const { createProject, inviteAndJoinMember } = await createOrg();
|
||||
const { createSavedFilter, getSavedFilter, getSavedFilters } = await createProject(
|
||||
ProjectType.Single,
|
||||
);
|
||||
|
||||
// Create a shared filter as owner
|
||||
const createResult = await createSavedFilter({
|
||||
name: 'Shared Filter',
|
||||
|
||||
visibility: SavedFilterVisibilityType.Shared,
|
||||
insightsFilter: { operationHashes: ['op1'] },
|
||||
});
|
||||
|
||||
const filterId = createResult.ok?.savedFilter.id!;
|
||||
|
||||
// Invite and join a member
|
||||
const { memberToken } = await inviteAndJoinMember();
|
||||
|
||||
// Member can see the shared filter
|
||||
const memberFilter = await getSavedFilter({ filterId, token: memberToken });
|
||||
expect(memberFilter?.id).toBe(filterId);
|
||||
expect(memberFilter?.name).toBe('Shared Filter');
|
||||
|
||||
// Member can see shared filter in list
|
||||
const memberFilters = await getSavedFilters({
|
||||
first: 10,
|
||||
token: memberToken,
|
||||
});
|
||||
expect(memberFilters.savedFilters?.edges.length).toBe(1);
|
||||
},
|
||||
);
|
||||
|
||||
test.concurrent(
|
||||
'shared filters should be updatable by any project member with sharedSavedFilter:modify permission',
|
||||
async ({ expect }) => {
|
||||
const { createOrg } = await initSeed().createOwner();
|
||||
const { createProject, inviteAndJoinMember } = await createOrg();
|
||||
const { createSavedFilter, updateSavedFilter, getSavedFilter } = await createProject(
|
||||
ProjectType.Single,
|
||||
);
|
||||
|
||||
// Create a shared filter as owner
|
||||
const createResult = await createSavedFilter({
|
||||
name: 'Shared Filter',
|
||||
|
||||
visibility: SavedFilterVisibilityType.Shared,
|
||||
insightsFilter: { operationHashes: ['op1'] },
|
||||
});
|
||||
|
||||
const filterId = createResult.ok?.savedFilter.id!;
|
||||
|
||||
// First invite a member to get access to createMemberRole
|
||||
const { createMemberRole } = await inviteAndJoinMember();
|
||||
|
||||
// Create a role with sharedSavedFilter:modify permission (without project:modifySettings)
|
||||
const role = await createMemberRole([
|
||||
'organization:describe',
|
||||
'project:describe',
|
||||
'sharedSavedFilter:modify',
|
||||
]);
|
||||
|
||||
// Invite and join a member with the sharedSavedFilter:modify role
|
||||
const { memberToken } = await inviteAndJoinMember({ memberRoleId: role.id });
|
||||
|
||||
// Member can see the filter
|
||||
const memberView = await getSavedFilter({ filterId, token: memberToken });
|
||||
expect(memberView?.viewerCanUpdate).toBe(true);
|
||||
expect(memberView?.viewerCanDelete).toBe(true);
|
||||
|
||||
// Member can update shared filter
|
||||
const updateResult = await updateSavedFilter({
|
||||
filterId,
|
||||
name: 'Updated by Member',
|
||||
token: memberToken,
|
||||
});
|
||||
|
||||
expect(updateResult.error).toBeNull();
|
||||
expect(updateResult.ok?.savedFilter.name).toBe('Updated by Member');
|
||||
},
|
||||
);
|
||||
|
||||
test.concurrent(
|
||||
'private filters can be managed by any project member without sharedSavedFilter:modify',
|
||||
async ({ expect }) => {
|
||||
const { createOrg } = await initSeed().createOwner();
|
||||
const { createProject, inviteAndJoinMember } = await createOrg();
|
||||
const { createSavedFilter, updateSavedFilter, deleteSavedFilter } = await createProject(
|
||||
ProjectType.Single,
|
||||
);
|
||||
|
||||
// First invite a member to get access to createMemberRole
|
||||
const { createMemberRole } = await inviteAndJoinMember();
|
||||
|
||||
// Create a role with only project:describe (no sharedSavedFilter:modify)
|
||||
const role = await createMemberRole(['organization:describe', 'project:describe']);
|
||||
|
||||
// Invite and join a member with the viewer-like role
|
||||
const { memberToken } = await inviteAndJoinMember({ memberRoleId: role.id });
|
||||
|
||||
// Member can create a private filter
|
||||
const createResult = await createSavedFilter({
|
||||
name: 'My Private Filter',
|
||||
|
||||
visibility: SavedFilterVisibilityType.Private,
|
||||
insightsFilter: { operationHashes: ['op1'] },
|
||||
token: memberToken,
|
||||
});
|
||||
|
||||
expect(createResult.error).toBeNull();
|
||||
expect(createResult.ok?.savedFilter.name).toBe('My Private Filter');
|
||||
expect(createResult.ok?.savedFilter.visibility).toBe('PRIVATE');
|
||||
|
||||
const filterId = createResult.ok?.savedFilter.id!;
|
||||
|
||||
// Member can update their own private filter
|
||||
const updateResult = await updateSavedFilter({
|
||||
filterId,
|
||||
name: 'Updated Private Filter',
|
||||
token: memberToken,
|
||||
});
|
||||
|
||||
expect(updateResult.error).toBeNull();
|
||||
expect(updateResult.ok?.savedFilter.name).toBe('Updated Private Filter');
|
||||
|
||||
// Member can delete their own private filter
|
||||
const deleteResult = await deleteSavedFilter({ filterId, token: memberToken });
|
||||
expect(deleteResult.error).toBeNull();
|
||||
expect(deleteResult.ok?.deletedId).toBe(filterId);
|
||||
},
|
||||
);
|
||||
|
||||
test.concurrent(
|
||||
'create shared filter: failure without sharedSavedFilter:modify permission',
|
||||
async ({ expect }) => {
|
||||
const { createOrg } = await initSeed().createOwner();
|
||||
const { createProject, inviteAndJoinMember } = await createOrg();
|
||||
const { createSavedFilter } = await createProject(ProjectType.Single);
|
||||
|
||||
// First invite a member to get access to createMemberRole
|
||||
const { createMemberRole } = await inviteAndJoinMember();
|
||||
|
||||
// Create a role with only project:describe (no sharedSavedFilter:modify)
|
||||
const role = await createMemberRole(['organization:describe', 'project:describe']);
|
||||
|
||||
// Invite and join a member with the viewer-like role
|
||||
const { memberToken } = await inviteAndJoinMember({ memberRoleId: role.id });
|
||||
|
||||
// Try to create a shared filter - should fail
|
||||
await expect(
|
||||
createSavedFilter({
|
||||
name: 'Unauthorized Shared Filter',
|
||||
|
||||
visibility: SavedFilterVisibilityType.Shared,
|
||||
insightsFilter: { operationHashes: ['op1'] },
|
||||
token: memberToken,
|
||||
}),
|
||||
).rejects.toEqual(
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining(
|
||||
`No access (reason: "Missing permission for performing 'sharedSavedFilter:modify' on resource")`,
|
||||
),
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('Validation', () => {
|
||||
test.concurrent('create: failure with empty name', async ({ expect }) => {
|
||||
const { createOrg } = await initSeed().createOwner();
|
||||
const { createProject } = await createOrg();
|
||||
const { createSavedFilter } = await createProject(ProjectType.Single);
|
||||
|
||||
const result = await createSavedFilter({
|
||||
name: '',
|
||||
|
||||
visibility: SavedFilterVisibilityType.Private,
|
||||
insightsFilter: { operationHashes: ['op1'] },
|
||||
});
|
||||
|
||||
expect(result.error).not.toBeNull();
|
||||
expect(result.error?.message).toContain('String must contain at least 1 character');
|
||||
});
|
||||
|
||||
test.concurrent('create: failure with name exceeding 100 characters', async ({ expect }) => {
|
||||
const { createOrg } = await initSeed().createOwner();
|
||||
const { createProject } = await createOrg();
|
||||
const { createSavedFilter } = await createProject(ProjectType.Single);
|
||||
|
||||
const result = await createSavedFilter({
|
||||
name: 'a'.repeat(101),
|
||||
|
||||
visibility: SavedFilterVisibilityType.Private,
|
||||
insightsFilter: { operationHashes: ['op1'] },
|
||||
});
|
||||
|
||||
expect(result.error).not.toBeNull();
|
||||
expect(result.error?.message).toContain('String must contain at most 100 character');
|
||||
});
|
||||
|
||||
test.concurrent('create: failure with too many operations', async ({ expect }) => {
|
||||
const { createOrg } = await initSeed().createOwner();
|
||||
const { createProject } = await createOrg();
|
||||
const { createSavedFilter } = await createProject(ProjectType.Single);
|
||||
|
||||
const result = await createSavedFilter({
|
||||
name: 'Too many ops',
|
||||
|
||||
visibility: SavedFilterVisibilityType.Private,
|
||||
insightsFilter: {
|
||||
operationHashes: Array.from({ length: 101 }, (_, i) => `op${i}`),
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.error).not.toBeNull();
|
||||
expect(result.error?.message).toContain('Array must contain at most 100 element');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -45,6 +45,7 @@
|
|||
"release:docs:update-version": "tsx scripts/sync-docker-image-tag-docs.ts",
|
||||
"release:version": "changeset version && pnpm --filter hive-apollo-router-plugin sync-cargo-file && pnpm build:libraries && pnpm --filter @graphql-hive/cli oclif:readme && pnpm run release:docs:update-version",
|
||||
"seed:app-deployments": "tsx scripts/seed-app-deployments.mts",
|
||||
"seed:insights": "tsx scripts/seed-insights.mts",
|
||||
"seed:org": "tsx scripts/seed-organization.mts",
|
||||
"seed:schemas": "tsx scripts/seed-schemas.ts",
|
||||
"seed:usage": "tsx scripts/seed-usage.ts",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
import { type MigrationExecutor } from '../pg-migrator';
|
||||
|
||||
export default {
|
||||
name: '2026.02.07T00-00-00.saved-filters.ts',
|
||||
run: ({ sql }) => sql`
|
||||
CREATE TYPE "saved_filter_visibility" AS ENUM ('private', 'shared');
|
||||
|
||||
CREATE TABLE "saved_filters" (
|
||||
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"project_id" uuid NOT NULL REFERENCES "projects"("id") ON DELETE CASCADE,
|
||||
"created_by_user_id" uuid NOT NULL REFERENCES "users"("id") ON DELETE CASCADE,
|
||||
"updated_by_user_id" uuid REFERENCES "users"("id") ON DELETE SET NULL,
|
||||
"name" text NOT NULL,
|
||||
"description" text,
|
||||
"filters" jsonb NOT NULL,
|
||||
"visibility" "saved_filter_visibility" NOT NULL,
|
||||
"views_count" integer NOT NULL DEFAULT 0,
|
||||
"created_at" timestamptz NOT NULL DEFAULT now(),
|
||||
"updated_at" timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX "saved_filters_project_visibility_pagination" ON "saved_filters" (
|
||||
"project_id" ASC,
|
||||
"visibility" ASC,
|
||||
"created_at" DESC,
|
||||
"id" DESC
|
||||
);
|
||||
|
||||
CREATE INDEX "saved_filters_user_project" ON "saved_filters" (
|
||||
"created_by_user_id" ASC,
|
||||
"project_id" ASC
|
||||
);
|
||||
|
||||
CREATE INDEX "saved_filters_project_views" ON "saved_filters" (
|
||||
"project_id" ASC,
|
||||
"views_count" DESC
|
||||
);
|
||||
`,
|
||||
} satisfies MigrationExecutor;
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
import { type MigrationExecutor } from '../pg-migrator';
|
||||
|
||||
export default {
|
||||
name: '2026.02.19T00-00-00.saved-filter-permission.ts',
|
||||
run: ({ sql }) => sql`
|
||||
UPDATE "organization_member_roles"
|
||||
SET "permissions" = array_append("permissions", 'sharedSavedFilter:modify')
|
||||
WHERE "permissions" @> ARRAY['project:modifySettings']
|
||||
AND NOT ("permissions" @> ARRAY['sharedSavedFilter:modify'])
|
||||
AND "locked" = false;
|
||||
`,
|
||||
} satisfies MigrationExecutor;
|
||||
|
|
@ -181,9 +181,11 @@ export const runPGMigrations = async (args: { slonik: DatabasePool; runTo?: stri
|
|||
await import('./actions/2026.01.25T00-00-00.checks-proposals-changes'),
|
||||
await import('./actions/2026.01.27T00-00-00.app-deployment-protection'),
|
||||
await import('./actions/2026.01.09T00-00-00.email-verifications'),
|
||||
await import('./actions/2026.02.07T00-00-00.saved-filters'),
|
||||
await import('./actions/2026.01.30T00-00-00.account-linking'),
|
||||
await import('./actions/2026.02.06T00-00-00.zendesk-unique'),
|
||||
await import('./actions/2026.01.30T10-00-00.oidc-require-invitation'),
|
||||
await import('./actions/2026.02.19T00-00-00.saved-filter-permission'),
|
||||
...(env.useSupertokensAtHome
|
||||
? [await import('./actions/2026.02.18T00-00-00.ensure-supertokens-tables')]
|
||||
: []),
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ import {
|
|||
import { projectModule } from './modules/project';
|
||||
import { proposalsModule } from './modules/proposals';
|
||||
import { SCHEMA_PROPOSALS_ENABLED } from './modules/proposals/providers/schema-proposals-enabled-token';
|
||||
import { savedFiltersModule } from './modules/saved-filters';
|
||||
import { schemaModule } from './modules/schema';
|
||||
import { ArtifactStorageWriter } from './modules/schema/providers/artifact-storage-writer';
|
||||
import { provideSchemaModuleConfig, SchemaModuleConfig } from './modules/schema/providers/config';
|
||||
|
|
@ -89,6 +90,7 @@ const modules = [
|
|||
oidcIntegrationsModule,
|
||||
schemaPolicyModule,
|
||||
collectionModule,
|
||||
savedFiltersModule,
|
||||
appDeploymentsModule,
|
||||
auditLogsModule,
|
||||
proposalsModule,
|
||||
|
|
|
|||
|
|
@ -354,6 +354,30 @@ export const AuditLogModel = z.union([
|
|||
organizationAccessTokenId: z.string().uuid(),
|
||||
}),
|
||||
}),
|
||||
z.object({
|
||||
eventType: z.literal('SAVED_FILTER_CREATED'),
|
||||
metadata: z.object({
|
||||
filterId: z.string().uuid(),
|
||||
filterName: z.string(),
|
||||
visibility: z.string(),
|
||||
projectId: z.string().uuid(),
|
||||
}),
|
||||
}),
|
||||
z.object({
|
||||
eventType: z.literal('SAVED_FILTER_UPDATED'),
|
||||
metadata: z.object({
|
||||
filterId: z.string().uuid(),
|
||||
filterName: z.string(),
|
||||
updatedFields: z.string(),
|
||||
}),
|
||||
}),
|
||||
z.object({
|
||||
eventType: z.literal('SAVED_FILTER_DELETED'),
|
||||
metadata: z.object({
|
||||
filterId: z.string().uuid(),
|
||||
filterName: z.string(),
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
|
||||
export type AuditLogSchemaEvent = z.infer<typeof AuditLogModel>;
|
||||
|
|
|
|||
|
|
@ -404,6 +404,7 @@ const permissionsByLevel = {
|
|||
z.literal('schemaLinting:modifyProjectRules'),
|
||||
z.literal('target:create'),
|
||||
z.literal('projectAccessToken:modify'),
|
||||
z.literal('sharedSavedFilter:modify'),
|
||||
],
|
||||
target: [
|
||||
z.literal('targetAccessToken:modify'),
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ export interface OperationsStatsMapper {
|
|||
period: DateRange;
|
||||
operations: readonly string[];
|
||||
clients: readonly string[];
|
||||
clientVersionFilters: readonly { clientName: string; versions: readonly string[] | null }[];
|
||||
}
|
||||
export interface DurationValuesMapper {
|
||||
avg: number | null;
|
||||
|
|
|
|||
|
|
@ -9,6 +9,21 @@ export default gql`
|
|||
monthlyUsage(selector: OrganizationSelectorInput!): [MonthlyUsage!]!
|
||||
}
|
||||
|
||||
"""
|
||||
Filter by specific client name + version combinations.
|
||||
"""
|
||||
input ClientVersionFilterInput {
|
||||
"""
|
||||
The client name to filter by.
|
||||
"""
|
||||
clientName: String! @tag(name: "public")
|
||||
"""
|
||||
Specific versions of this client to include.
|
||||
When null, all versions of this client are included.
|
||||
"""
|
||||
versions: [String!] @tag(name: "public")
|
||||
}
|
||||
|
||||
input OperationStatsFilterInput {
|
||||
"""
|
||||
Filter by only showing operations with a specific id.
|
||||
|
|
@ -17,7 +32,16 @@ export default gql`
|
|||
"""
|
||||
Filter by only showing operations performed by specific clients.
|
||||
"""
|
||||
clientNames: [String!] @tag(name: "public")
|
||||
clientNames: [String!]
|
||||
@tag(name: "public")
|
||||
@deprecated(
|
||||
reason: "Use 'clientVersionFilters' instead for more precise filtering by client name and version."
|
||||
)
|
||||
"""
|
||||
Filter by specific client name + version combinations.
|
||||
More precise than clientNames - allows filtering to specific versions.
|
||||
"""
|
||||
clientVersionFilters: [ClientVersionFilterInput!] @tag(name: "public")
|
||||
}
|
||||
|
||||
extend type Target {
|
||||
|
|
|
|||
|
|
@ -138,10 +138,12 @@ export class OperationsManager {
|
|||
period,
|
||||
operations,
|
||||
clients,
|
||||
clientVersionFilters,
|
||||
}: {
|
||||
period: DateRange;
|
||||
operations?: readonly string[];
|
||||
clients?: readonly string[];
|
||||
clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[];
|
||||
} & TargetSelector) {
|
||||
this.logger.info('Counting unique operations (period=%o, target=%s)', period, target);
|
||||
await this.session.assertPerformAction({
|
||||
|
|
@ -158,6 +160,7 @@ export class OperationsManager {
|
|||
period,
|
||||
operations,
|
||||
clients,
|
||||
clientVersionFilters,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -244,10 +247,12 @@ export class OperationsManager {
|
|||
period,
|
||||
operations,
|
||||
clients,
|
||||
clientVersionFilters,
|
||||
}: {
|
||||
period: DateRange;
|
||||
operations?: readonly string[];
|
||||
clients?: readonly string[];
|
||||
clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[];
|
||||
} & Listify<TargetSelector, 'targetId'>) {
|
||||
this.logger.info('Counting requests and failures (period=%o, target=%s)', period, target);
|
||||
await this.session.assertPerformAction({
|
||||
|
|
@ -265,6 +270,7 @@ export class OperationsManager {
|
|||
period,
|
||||
operations,
|
||||
clients,
|
||||
clientVersionFilters,
|
||||
})
|
||||
.then(r => r.total);
|
||||
}
|
||||
|
|
@ -320,10 +326,12 @@ export class OperationsManager {
|
|||
period,
|
||||
operations,
|
||||
clients,
|
||||
clientVersionFilters,
|
||||
}: {
|
||||
period: DateRange;
|
||||
operations?: readonly string[];
|
||||
clients?: readonly string[];
|
||||
clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[];
|
||||
} & TargetSelector) {
|
||||
this.logger.info('Counting failures (period=%o, target=%s)', period, target);
|
||||
await this.session.assertPerformAction({
|
||||
|
|
@ -340,6 +348,7 @@ export class OperationsManager {
|
|||
period,
|
||||
operations,
|
||||
clients,
|
||||
clientVersionFilters,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -435,11 +444,13 @@ export class OperationsManager {
|
|||
targetId: target,
|
||||
operations,
|
||||
clients,
|
||||
clientVersionFilters,
|
||||
schemaCoordinate,
|
||||
}: {
|
||||
period: DateRange;
|
||||
operations?: readonly string[];
|
||||
clients?: readonly string[];
|
||||
clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[];
|
||||
schemaCoordinate?: string;
|
||||
} & TargetSelector) {
|
||||
this.logger.info('Reading operations stats (period=%o, target=%s)', period, target);
|
||||
|
|
@ -458,6 +469,7 @@ export class OperationsManager {
|
|||
period,
|
||||
operations,
|
||||
clients,
|
||||
clientVersionFilters,
|
||||
schemaCoordinate,
|
||||
});
|
||||
}
|
||||
|
|
@ -571,12 +583,14 @@ export class OperationsManager {
|
|||
targetId: target,
|
||||
operations,
|
||||
clients,
|
||||
clientVersionFilters,
|
||||
schemaCoordinate,
|
||||
}: {
|
||||
period: DateRange;
|
||||
resolution: number;
|
||||
operations?: readonly string[];
|
||||
clients?: readonly string[];
|
||||
clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[];
|
||||
schemaCoordinate?: string;
|
||||
} & TargetSelector) {
|
||||
this.logger.info(
|
||||
|
|
@ -600,6 +614,7 @@ export class OperationsManager {
|
|||
resolution,
|
||||
operations,
|
||||
clients,
|
||||
clientVersionFilters,
|
||||
schemaCoordinate,
|
||||
});
|
||||
}
|
||||
|
|
@ -612,11 +627,13 @@ export class OperationsManager {
|
|||
targetId: target,
|
||||
operations,
|
||||
clients,
|
||||
clientVersionFilters,
|
||||
}: {
|
||||
period: DateRange;
|
||||
resolution: number;
|
||||
operations?: readonly string[];
|
||||
clients?: readonly string[];
|
||||
clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[];
|
||||
} & TargetSelector) {
|
||||
this.logger.info(
|
||||
'Reading failures over time (period=%o, resolution=%s, target=%s)',
|
||||
|
|
@ -639,6 +656,7 @@ export class OperationsManager {
|
|||
resolution,
|
||||
operations,
|
||||
clients,
|
||||
clientVersionFilters,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -650,11 +668,13 @@ export class OperationsManager {
|
|||
targetId: target,
|
||||
operations,
|
||||
clients,
|
||||
clientVersionFilters,
|
||||
}: {
|
||||
period: DateRange;
|
||||
resolution: number;
|
||||
operations?: readonly string[];
|
||||
clients?: readonly string[];
|
||||
clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[];
|
||||
} & TargetSelector) {
|
||||
this.logger.info(
|
||||
'Reading duration over time (period=%o, resolution=%s, target=%s)',
|
||||
|
|
@ -677,6 +697,7 @@ export class OperationsManager {
|
|||
resolution,
|
||||
operations,
|
||||
clients,
|
||||
clientVersionFilters,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -687,10 +708,12 @@ export class OperationsManager {
|
|||
targetId: target,
|
||||
operations,
|
||||
clients,
|
||||
clientVersionFilters,
|
||||
}: {
|
||||
period: DateRange;
|
||||
operations?: readonly string[];
|
||||
clients?: readonly string[];
|
||||
clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[];
|
||||
} & TargetSelector) {
|
||||
this.logger.info('Reading overall duration percentiles (period=%o, target=%s)', period, target);
|
||||
await this.session.assertPerformAction({
|
||||
|
|
@ -707,6 +730,7 @@ export class OperationsManager {
|
|||
period,
|
||||
operations,
|
||||
clients,
|
||||
clientVersionFilters,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -718,11 +742,13 @@ export class OperationsManager {
|
|||
targetId: target,
|
||||
operations,
|
||||
clients,
|
||||
clientVersionFilters,
|
||||
schemaCoordinate,
|
||||
}: {
|
||||
period: DateRange;
|
||||
operations?: readonly string[];
|
||||
clients?: readonly string[];
|
||||
clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[];
|
||||
schemaCoordinate?: string;
|
||||
} & TargetSelector) {
|
||||
this.logger.info(
|
||||
|
|
@ -745,6 +771,7 @@ export class OperationsManager {
|
|||
period,
|
||||
operations,
|
||||
clients,
|
||||
clientVersionFilters,
|
||||
schemaCoordinate,
|
||||
});
|
||||
}
|
||||
|
|
@ -756,11 +783,13 @@ export class OperationsManager {
|
|||
targetId: target,
|
||||
operations,
|
||||
clients,
|
||||
clientVersionFilters,
|
||||
schemaCoordinate,
|
||||
}: {
|
||||
period: DateRange;
|
||||
operations?: readonly string[];
|
||||
clients?: readonly string[];
|
||||
clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[];
|
||||
schemaCoordinate?: string;
|
||||
} & TargetSelector) {
|
||||
this.logger.info('Counting unique clients (period=%o, target=%s)', period, target);
|
||||
|
|
@ -778,6 +807,7 @@ export class OperationsManager {
|
|||
period,
|
||||
operations,
|
||||
clients,
|
||||
clientVersionFilters,
|
||||
schemaCoordinate,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -455,12 +455,14 @@ export class OperationsReader {
|
|||
period,
|
||||
operations,
|
||||
clients,
|
||||
clientVersionFilters,
|
||||
schemaCoordinate,
|
||||
}: {
|
||||
target: string | readonly string[];
|
||||
period: DateRange;
|
||||
operations?: readonly string[];
|
||||
clients?: readonly string[];
|
||||
clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[];
|
||||
schemaCoordinate?: string;
|
||||
}): Promise<{
|
||||
total: number;
|
||||
|
|
@ -481,6 +483,7 @@ export class OperationsReader {
|
|||
period,
|
||||
operations,
|
||||
clients,
|
||||
clientVersionFilters,
|
||||
extra: schemaCoordinate
|
||||
? [
|
||||
sql`hash IN (SELECT hash FROM ${aggregationTableName('coordinates')} ${this.createFilter(
|
||||
|
|
@ -517,13 +520,17 @@ export class OperationsReader {
|
|||
period,
|
||||
operations,
|
||||
clients,
|
||||
clientVersionFilters,
|
||||
}: {
|
||||
target: string;
|
||||
period: DateRange;
|
||||
operations?: readonly string[];
|
||||
clients?: readonly string[];
|
||||
clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[];
|
||||
}): Promise<number> {
|
||||
return this.countRequests({ target, period, operations, clients }).then(r => r.notOk);
|
||||
return this.countRequests({ target, period, operations, clients, clientVersionFilters }).then(
|
||||
r => r.notOk,
|
||||
);
|
||||
}
|
||||
|
||||
async countUniqueDocuments({
|
||||
|
|
@ -531,11 +538,13 @@ export class OperationsReader {
|
|||
period,
|
||||
operations,
|
||||
clients,
|
||||
clientVersionFilters,
|
||||
}: {
|
||||
target: string;
|
||||
period: DateRange;
|
||||
operations?: readonly string[];
|
||||
clients?: readonly string[];
|
||||
clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[];
|
||||
}): Promise<number> {
|
||||
const query = this.pickAggregationByPeriod({
|
||||
period,
|
||||
|
|
@ -551,6 +560,7 @@ export class OperationsReader {
|
|||
period,
|
||||
operations,
|
||||
clients,
|
||||
clientVersionFilters,
|
||||
},
|
||||
)}`,
|
||||
queryId: aggregation => `count_unique_documents_${aggregation}`,
|
||||
|
|
@ -568,12 +578,14 @@ export class OperationsReader {
|
|||
period,
|
||||
operations,
|
||||
clients,
|
||||
clientVersionFilters,
|
||||
schemaCoordinate,
|
||||
}: {
|
||||
target: string;
|
||||
period: DateRange;
|
||||
operations?: readonly string[];
|
||||
clients?: readonly string[];
|
||||
clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[];
|
||||
schemaCoordinate?: string;
|
||||
}): Promise<
|
||||
Array<{
|
||||
|
|
@ -600,6 +612,7 @@ export class OperationsReader {
|
|||
period,
|
||||
operations,
|
||||
clients,
|
||||
clientVersionFilters,
|
||||
extra: schemaCoordinate
|
||||
? [
|
||||
sql`hash IN (SELECT hash FROM ${aggregationTableName('coordinates')} ${this.createFilter(
|
||||
|
|
@ -643,6 +656,7 @@ export class OperationsReader {
|
|||
target,
|
||||
period,
|
||||
operations,
|
||||
clientVersionFilters,
|
||||
extra: schemaCoordinate
|
||||
? [
|
||||
sql`hash IN (SELECT hash FROM ${sql.raw('coordinates_' + query.queryType)} ${this.createFilter(
|
||||
|
|
@ -796,12 +810,14 @@ export class OperationsReader {
|
|||
period,
|
||||
operations,
|
||||
clients,
|
||||
clientVersionFilters,
|
||||
schemaCoordinate,
|
||||
}: {
|
||||
target: string;
|
||||
period: DateRange;
|
||||
operations?: readonly string[];
|
||||
clients?: readonly string[];
|
||||
clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[];
|
||||
schemaCoordinate?: string;
|
||||
}): Promise<
|
||||
Array<{
|
||||
|
|
@ -832,6 +848,7 @@ export class OperationsReader {
|
|||
period,
|
||||
operations,
|
||||
clients,
|
||||
clientVersionFilters,
|
||||
extra: schemaCoordinate
|
||||
? [
|
||||
sql`hash IN (SELECT hash FROM ${aggregationTableName('coordinates')} ${this.createFilter(
|
||||
|
|
@ -1713,6 +1730,7 @@ export class OperationsReader {
|
|||
resolution,
|
||||
operations,
|
||||
clients,
|
||||
clientVersionFilters,
|
||||
schemaCoordinate,
|
||||
}: {
|
||||
target: string;
|
||||
|
|
@ -1720,6 +1738,7 @@ export class OperationsReader {
|
|||
resolution: number;
|
||||
operations?: readonly string[];
|
||||
clients?: readonly string[];
|
||||
clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[];
|
||||
schemaCoordinate?: string;
|
||||
}) {
|
||||
const results = await this.getDurationAndCountOverTime({
|
||||
|
|
@ -1728,6 +1747,7 @@ export class OperationsReader {
|
|||
resolution,
|
||||
operations,
|
||||
clients,
|
||||
clientVersionFilters,
|
||||
schemaCoordinate,
|
||||
});
|
||||
|
||||
|
|
@ -1743,12 +1763,14 @@ export class OperationsReader {
|
|||
resolution,
|
||||
operations,
|
||||
clients,
|
||||
clientVersionFilters,
|
||||
}: {
|
||||
target: string;
|
||||
period: DateRange;
|
||||
resolution: number;
|
||||
operations?: readonly string[];
|
||||
clients?: readonly string[];
|
||||
clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[];
|
||||
}) {
|
||||
const result = await this.getDurationAndCountOverTime({
|
||||
target,
|
||||
|
|
@ -1756,6 +1778,7 @@ export class OperationsReader {
|
|||
resolution,
|
||||
operations,
|
||||
clients,
|
||||
clientVersionFilters,
|
||||
});
|
||||
|
||||
return result.map(row => ({
|
||||
|
|
@ -1770,10 +1793,12 @@ export class OperationsReader {
|
|||
resolution,
|
||||
operations,
|
||||
clients,
|
||||
clientVersionFilters,
|
||||
}: {
|
||||
target: string;
|
||||
period: DateRange;
|
||||
resolution: number;
|
||||
clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[];
|
||||
operations?: readonly string[];
|
||||
clients?: readonly string[];
|
||||
}): Promise<
|
||||
|
|
@ -1788,6 +1813,7 @@ export class OperationsReader {
|
|||
resolution,
|
||||
operations,
|
||||
clients,
|
||||
clientVersionFilters,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -1796,11 +1822,13 @@ export class OperationsReader {
|
|||
period,
|
||||
operations,
|
||||
clients,
|
||||
clientVersionFilters,
|
||||
}: {
|
||||
target: string;
|
||||
period: DateRange;
|
||||
operations?: readonly string[];
|
||||
clients?: readonly string[];
|
||||
clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[];
|
||||
}): Promise<DurationMetrics> {
|
||||
const result = await this.clickHouse.query<{
|
||||
percentiles: [number, number, number, number];
|
||||
|
|
@ -1812,7 +1840,7 @@ export class OperationsReader {
|
|||
avgMerge(duration_avg) as average,
|
||||
quantilesMerge(0.75, 0.90, 0.95, 0.99)(duration_quantiles) as percentiles
|
||||
FROM ${aggregationTableName('operations')}
|
||||
${this.createFilter({ target, period, operations, clients })}
|
||||
${this.createFilter({ target, period, operations, clients, clientVersionFilters })}
|
||||
`,
|
||||
queryId: aggregation => `general_duration_percentiles_${aggregation}`,
|
||||
timeout: 15_000,
|
||||
|
|
@ -1828,8 +1856,10 @@ export class OperationsReader {
|
|||
period,
|
||||
operations,
|
||||
clients,
|
||||
clientVersionFilters,
|
||||
schemaCoordinate,
|
||||
}: {
|
||||
clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[];
|
||||
target: string;
|
||||
period: DateRange;
|
||||
operations?: readonly string[];
|
||||
|
|
@ -1853,6 +1883,7 @@ export class OperationsReader {
|
|||
period,
|
||||
operations,
|
||||
clients,
|
||||
clientVersionFilters,
|
||||
extra: schemaCoordinate
|
||||
? [
|
||||
sql`hash IN (SELECT hash FROM ${aggregationTableName('coordinates')} ${this.createFilter(
|
||||
|
|
@ -1914,6 +1945,7 @@ export class OperationsReader {
|
|||
resolution,
|
||||
operations,
|
||||
clients,
|
||||
clientVersionFilters,
|
||||
schemaCoordinate,
|
||||
}: {
|
||||
target: string;
|
||||
|
|
@ -1921,6 +1953,7 @@ export class OperationsReader {
|
|||
resolution: number;
|
||||
operations?: readonly string[];
|
||||
clients?: readonly string[];
|
||||
clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[];
|
||||
schemaCoordinate?: string;
|
||||
}) {
|
||||
const interval = calculateTimeWindow({ period, resolution });
|
||||
|
|
@ -1963,6 +1996,7 @@ export class OperationsReader {
|
|||
period: roundedPeriod,
|
||||
operations,
|
||||
clients,
|
||||
clientVersionFilters,
|
||||
extra: schemaCoordinate
|
||||
? [
|
||||
sql`hash IN (SELECT hash FROM ${aggregationTableName('coordinates')} ${this.createFilter(
|
||||
|
|
@ -2255,6 +2289,7 @@ export class OperationsReader {
|
|||
period,
|
||||
operations,
|
||||
clients,
|
||||
clientVersionFilters,
|
||||
extra = [],
|
||||
skipWhere = false,
|
||||
namespace,
|
||||
|
|
@ -2263,6 +2298,7 @@ export class OperationsReader {
|
|||
period?: DateRange;
|
||||
operations?: readonly string[];
|
||||
clients?: readonly string[];
|
||||
clientVersionFilters?: readonly { clientName: string; versions: readonly string[] | null }[];
|
||||
extra?: SqlValue[];
|
||||
skipWhere?: boolean;
|
||||
namespace?: string;
|
||||
|
|
@ -2294,6 +2330,19 @@ export class OperationsReader {
|
|||
where.push(sql`${sql.raw(namespace ?? '')}client_name IN (${sql.array(clients, 'String')})`);
|
||||
}
|
||||
|
||||
if (clientVersionFilters?.length) {
|
||||
// Build OR conditions for each client+versions combination
|
||||
const versionConditions = clientVersionFilters.map(filter => {
|
||||
const clientName = filter.clientName === 'unknown' ? '' : filter.clientName;
|
||||
if (!filter.versions?.length) {
|
||||
// null/empty versions = all versions of this client
|
||||
return sql`(${columnPrefix}client_name = ${clientName})`;
|
||||
}
|
||||
return sql`(${columnPrefix}client_name = ${clientName} AND ${columnPrefix}client_version IN (${sql.array(filter.versions, 'String')}))`;
|
||||
});
|
||||
where.push(sql`(${sql.join(versionConditions, ' OR ')})`);
|
||||
}
|
||||
|
||||
if (extra.length) {
|
||||
where.push(...extra);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,15 @@ import type { OperationsStatsResolvers } from './../../../__generated__/types';
|
|||
|
||||
export const OperationsStats: OperationsStatsResolvers = {
|
||||
operations: async (
|
||||
{ organization, project, target, period, operations: operationsFilter, clients },
|
||||
{
|
||||
organization,
|
||||
project,
|
||||
target,
|
||||
period,
|
||||
operations: operationsFilter,
|
||||
clients,
|
||||
clientVersionFilters,
|
||||
},
|
||||
_,
|
||||
{ injector },
|
||||
) => {
|
||||
|
|
@ -17,6 +25,7 @@ export const OperationsStats: OperationsStatsResolvers = {
|
|||
period,
|
||||
operations: operationsFilter,
|
||||
clients,
|
||||
clientVersionFilters,
|
||||
}),
|
||||
operationsManager.readDetailedDurationMetrics({
|
||||
organizationId: organization,
|
||||
|
|
@ -25,6 +34,7 @@ export const OperationsStats: OperationsStatsResolvers = {
|
|||
period,
|
||||
operations: operationsFilter,
|
||||
clients,
|
||||
clientVersionFilters,
|
||||
}),
|
||||
]);
|
||||
|
||||
|
|
@ -54,7 +64,7 @@ export const OperationsStats: OperationsStatsResolvers = {
|
|||
};
|
||||
},
|
||||
totalRequests: (
|
||||
{ organization, project, target, period, operations, clients },
|
||||
{ organization, project, target, period, operations, clients, clientVersionFilters },
|
||||
_,
|
||||
{ injector },
|
||||
) => {
|
||||
|
|
@ -65,10 +75,19 @@ export const OperationsStats: OperationsStatsResolvers = {
|
|||
period,
|
||||
operations,
|
||||
clients,
|
||||
clientVersionFilters,
|
||||
});
|
||||
},
|
||||
totalFailures: (
|
||||
{ organization, project, target, period, operations: operationsFilter, clients },
|
||||
{
|
||||
organization,
|
||||
project,
|
||||
target,
|
||||
period,
|
||||
operations: operationsFilter,
|
||||
clients,
|
||||
clientVersionFilters,
|
||||
},
|
||||
_,
|
||||
{ injector },
|
||||
) => {
|
||||
|
|
@ -79,10 +98,19 @@ export const OperationsStats: OperationsStatsResolvers = {
|
|||
period,
|
||||
operations: operationsFilter,
|
||||
clients,
|
||||
clientVersionFilters,
|
||||
});
|
||||
},
|
||||
totalOperations: (
|
||||
{ organization, project, target, period, operations: operationsFilter, clients },
|
||||
{
|
||||
organization,
|
||||
project,
|
||||
target,
|
||||
period,
|
||||
operations: operationsFilter,
|
||||
clients,
|
||||
clientVersionFilters,
|
||||
},
|
||||
_,
|
||||
{ injector },
|
||||
) => {
|
||||
|
|
@ -93,10 +121,19 @@ export const OperationsStats: OperationsStatsResolvers = {
|
|||
period,
|
||||
operations: operationsFilter,
|
||||
clients,
|
||||
clientVersionFilters,
|
||||
});
|
||||
},
|
||||
requestsOverTime: (
|
||||
{ organization, project, target, period, operations: operationsFilter, clients },
|
||||
{
|
||||
organization,
|
||||
project,
|
||||
target,
|
||||
period,
|
||||
operations: operationsFilter,
|
||||
clients,
|
||||
clientVersionFilters,
|
||||
},
|
||||
{ resolution },
|
||||
{ injector },
|
||||
) => {
|
||||
|
|
@ -108,10 +145,19 @@ export const OperationsStats: OperationsStatsResolvers = {
|
|||
resolution,
|
||||
operations: operationsFilter,
|
||||
clients,
|
||||
clientVersionFilters,
|
||||
});
|
||||
},
|
||||
failuresOverTime: (
|
||||
{ organization, project, target, period, operations: operationsFilter, clients },
|
||||
{
|
||||
organization,
|
||||
project,
|
||||
target,
|
||||
period,
|
||||
operations: operationsFilter,
|
||||
clients,
|
||||
clientVersionFilters,
|
||||
},
|
||||
{ resolution },
|
||||
{ injector },
|
||||
) => {
|
||||
|
|
@ -123,10 +169,19 @@ export const OperationsStats: OperationsStatsResolvers = {
|
|||
resolution,
|
||||
operations: operationsFilter,
|
||||
clients,
|
||||
clientVersionFilters,
|
||||
});
|
||||
},
|
||||
durationOverTime: (
|
||||
{ organization, project, target, period, operations: operationsFilter, clients },
|
||||
{
|
||||
organization,
|
||||
project,
|
||||
target,
|
||||
period,
|
||||
operations: operationsFilter,
|
||||
clients,
|
||||
clientVersionFilters,
|
||||
},
|
||||
{ resolution },
|
||||
{ injector },
|
||||
) => {
|
||||
|
|
@ -138,10 +193,19 @@ export const OperationsStats: OperationsStatsResolvers = {
|
|||
resolution,
|
||||
operations: operationsFilter,
|
||||
clients,
|
||||
clientVersionFilters,
|
||||
});
|
||||
},
|
||||
clients: async (
|
||||
{ organization, project, target, period, operations: operationsFilter, clients },
|
||||
{
|
||||
organization,
|
||||
project,
|
||||
target,
|
||||
period,
|
||||
operations: operationsFilter,
|
||||
clients,
|
||||
clientVersionFilters,
|
||||
},
|
||||
_,
|
||||
{ injector },
|
||||
) => {
|
||||
|
|
@ -152,6 +216,7 @@ export const OperationsStats: OperationsStatsResolvers = {
|
|||
period,
|
||||
operations: operationsFilter,
|
||||
clients,
|
||||
clientVersionFilters,
|
||||
});
|
||||
|
||||
return {
|
||||
|
|
@ -165,7 +230,15 @@ export const OperationsStats: OperationsStatsResolvers = {
|
|||
};
|
||||
},
|
||||
duration: (
|
||||
{ organization, project, target, period, operations: operationsFilter, clients },
|
||||
{
|
||||
organization,
|
||||
project,
|
||||
target,
|
||||
period,
|
||||
operations: operationsFilter,
|
||||
clients,
|
||||
clientVersionFilters,
|
||||
},
|
||||
_,
|
||||
{ injector },
|
||||
) => {
|
||||
|
|
@ -176,6 +249,7 @@ export const OperationsStats: OperationsStatsResolvers = {
|
|||
period,
|
||||
operations: operationsFilter,
|
||||
clients,
|
||||
clientVersionFilters,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
import { GraphQLError } from 'graphql';
|
||||
import { parseDateRangeInput } from '../../../shared/helpers';
|
||||
import { OperationsManager } from '../providers/operations-manager';
|
||||
import { Traces } from '../providers/traces';
|
||||
import type { TargetResolvers } from './../../../__generated__/types';
|
||||
|
||||
const MAX_CLIENT_VERSION_FILTERS = 50;
|
||||
const MAX_VERSIONS_PER_FILTER = 100;
|
||||
|
||||
export const Target: Pick<
|
||||
TargetResolvers,
|
||||
| 'clientStats'
|
||||
|
|
@ -54,6 +58,23 @@ export const Target: Pick<
|
|||
};
|
||||
},
|
||||
operationsStats: async (target, args, _ctx) => {
|
||||
// Validate clientVersionFilters size limits to prevent DoS via large SQL IN clauses
|
||||
const clientVersionFilters = args.filter?.clientVersionFilters;
|
||||
if (clientVersionFilters) {
|
||||
if (clientVersionFilters.length > MAX_CLIENT_VERSION_FILTERS) {
|
||||
throw new GraphQLError(
|
||||
`'OperationStatsFilterInput.clientVersionFilters' must contain at most ${MAX_CLIENT_VERSION_FILTERS} elements`,
|
||||
);
|
||||
}
|
||||
for (const filter of clientVersionFilters) {
|
||||
if (filter.versions && filter.versions.length > MAX_VERSIONS_PER_FILTER) {
|
||||
throw new GraphQLError(
|
||||
`'ClientVersionFilterInput.versions' must contain at most ${MAX_VERSIONS_PER_FILTER} elements`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
period: parseDateRangeInput(args.period),
|
||||
organization: target.orgId,
|
||||
|
|
@ -64,6 +85,11 @@ export const Target: Pick<
|
|||
// TODO: figure out if the mapping should actually happen here :thinking:
|
||||
args.filter?.clientNames?.map(clientName => (clientName === 'unknown' ? '' : clientName)) ??
|
||||
[],
|
||||
clientVersionFilters:
|
||||
clientVersionFilters?.map(f => ({
|
||||
clientName: f.clientName === 'unknown' ? '' : f.clientName,
|
||||
versions: f.versions ? [...f.versions] : null,
|
||||
})) ?? [],
|
||||
};
|
||||
},
|
||||
schemaCoordinateStats: async (target, args, _ctx) => {
|
||||
|
|
|
|||
|
|
@ -148,6 +148,19 @@ export const permissionGroups: Array<PermissionGroup> = [
|
|||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'saved-filters',
|
||||
title: 'Saved Filters',
|
||||
permissions: [
|
||||
{
|
||||
id: 'sharedSavedFilter:modify',
|
||||
title: 'Manage shared saved filters',
|
||||
description:
|
||||
'Member can create, update, and delete shared saved filters. All members can manage their own private filters.',
|
||||
dependsOn: 'project:describe',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'schema-linting',
|
||||
title: 'Schema Linting',
|
||||
|
|
|
|||
14
packages/services/api/src/modules/saved-filters/index.ts
Normal file
14
packages/services/api/src/modules/saved-filters/index.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { createModule } from 'graphql-modules';
|
||||
import { AuditLogManager } from '../audit-logs/providers/audit-logs-manager';
|
||||
import { SavedFiltersStorage } from './providers/saved-filters-storage';
|
||||
import { SavedFiltersProvider } from './providers/saved-filters.provider';
|
||||
import { resolvers } from './resolvers.generated';
|
||||
import { typeDefs } from './module.graphql';
|
||||
|
||||
export const savedFiltersModule = createModule({
|
||||
id: 'saved-filters',
|
||||
dirname: __dirname,
|
||||
typeDefs,
|
||||
resolvers,
|
||||
providers: [SavedFiltersProvider, SavedFiltersStorage, AuditLogManager],
|
||||
});
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import type { SavedFilter } from '../../shared/entities';
|
||||
|
||||
export type SavedFilterMapper = SavedFilter & {
|
||||
orgId?: string;
|
||||
};
|
||||
|
|
@ -0,0 +1,163 @@
|
|||
import { gql } from 'graphql-modules';
|
||||
|
||||
export const typeDefs = gql`
|
||||
enum SavedFilterVisibilityType {
|
||||
PRIVATE
|
||||
SHARED
|
||||
}
|
||||
|
||||
type SavedFilter {
|
||||
id: ID!
|
||||
name: String!
|
||||
description: String
|
||||
filters: InsightsFilterConfiguration!
|
||||
visibility: SavedFilterVisibilityType!
|
||||
viewsCount: Int!
|
||||
createdAt: DateTime!
|
||||
updatedAt: DateTime!
|
||||
createdBy: User
|
||||
updatedBy: User
|
||||
viewerCanUpdate: Boolean!
|
||||
viewerCanDelete: Boolean!
|
||||
}
|
||||
|
||||
type InsightsFilterConfiguration {
|
||||
operationHashes: [String!]!
|
||||
clientFilters: [ClientFilter!]!
|
||||
dateRange: InsightsDateRange
|
||||
}
|
||||
|
||||
type ClientFilter {
|
||||
name: String!
|
||||
versions: [String!]
|
||||
}
|
||||
|
||||
type InsightsDateRange {
|
||||
from: String!
|
||||
to: String!
|
||||
}
|
||||
|
||||
type SavedFilterEdge {
|
||||
node: SavedFilter!
|
||||
cursor: String!
|
||||
}
|
||||
|
||||
type SavedFilterConnection {
|
||||
edges: [SavedFilterEdge!]!
|
||||
pageInfo: PageInfo!
|
||||
}
|
||||
|
||||
input CreateSavedFilterInput {
|
||||
target: TargetReferenceInput!
|
||||
name: String!
|
||||
description: String
|
||||
visibility: SavedFilterVisibilityType!
|
||||
insightsFilter: InsightsFilterConfigurationInput
|
||||
}
|
||||
|
||||
input UpdateSavedFilterInput {
|
||||
target: TargetReferenceInput!
|
||||
id: ID!
|
||||
name: String
|
||||
description: String
|
||||
visibility: SavedFilterVisibilityType
|
||||
insightsFilter: InsightsFilterConfigurationInput
|
||||
}
|
||||
|
||||
input DeleteSavedFilterInput {
|
||||
target: TargetReferenceInput!
|
||||
id: ID!
|
||||
}
|
||||
|
||||
input TrackSavedFilterViewInput {
|
||||
target: TargetReferenceInput!
|
||||
id: ID!
|
||||
}
|
||||
|
||||
input InsightsFilterConfigurationInput {
|
||||
operationHashes: [String!]
|
||||
clientFilters: [ClientFilterInput!]
|
||||
dateRange: InsightsDateRangeInput
|
||||
}
|
||||
|
||||
input InsightsDateRangeInput {
|
||||
from: String!
|
||||
to: String!
|
||||
}
|
||||
|
||||
input ClientFilterInput {
|
||||
name: String!
|
||||
versions: [String!]
|
||||
}
|
||||
|
||||
extend type Target {
|
||||
savedFilter(id: ID!): SavedFilter
|
||||
savedFilters(
|
||||
first: Int = 50
|
||||
after: String
|
||||
visibility: SavedFilterVisibilityType
|
||||
search: String
|
||||
): SavedFilterConnection!
|
||||
viewerCanCreateSavedFilter: Boolean!
|
||||
viewerCanShareSavedFilter: Boolean!
|
||||
}
|
||||
|
||||
extend type Mutation {
|
||||
createSavedFilter(input: CreateSavedFilterInput!): CreateSavedFilterResult!
|
||||
updateSavedFilter(input: UpdateSavedFilterInput!): UpdateSavedFilterResult!
|
||||
deleteSavedFilter(input: DeleteSavedFilterInput!): DeleteSavedFilterResult!
|
||||
trackSavedFilterView(input: TrackSavedFilterViewInput!): TrackSavedFilterViewResult!
|
||||
}
|
||||
|
||||
type SavedFilterError implements Error {
|
||||
message: String!
|
||||
}
|
||||
|
||||
"""
|
||||
@oneOf
|
||||
"""
|
||||
type CreateSavedFilterResult {
|
||||
ok: CreateSavedFilterOkPayload
|
||||
error: SavedFilterError
|
||||
}
|
||||
|
||||
type CreateSavedFilterOkPayload {
|
||||
savedFilter: SavedFilter!
|
||||
}
|
||||
|
||||
"""
|
||||
@oneOf
|
||||
"""
|
||||
type UpdateSavedFilterResult {
|
||||
ok: UpdateSavedFilterOkPayload
|
||||
error: SavedFilterError
|
||||
}
|
||||
|
||||
type UpdateSavedFilterOkPayload {
|
||||
savedFilter: SavedFilter!
|
||||
}
|
||||
|
||||
"""
|
||||
@oneOf
|
||||
"""
|
||||
type DeleteSavedFilterResult {
|
||||
ok: DeleteSavedFilterOkPayload
|
||||
error: SavedFilterError
|
||||
}
|
||||
|
||||
type DeleteSavedFilterOkPayload {
|
||||
deletedId: ID!
|
||||
}
|
||||
|
||||
"""
|
||||
@oneOf
|
||||
"""
|
||||
type TrackSavedFilterViewResult {
|
||||
ok: TrackSavedFilterViewOkPayload
|
||||
error: SavedFilterError
|
||||
}
|
||||
|
||||
type TrackSavedFilterViewOkPayload {
|
||||
savedFilter: SavedFilter!
|
||||
}
|
||||
`;
|
||||
|
|
@ -0,0 +1,297 @@
|
|||
import { Inject, Injectable, Scope } from 'graphql-modules';
|
||||
import { sql, type DatabasePool } from 'slonik';
|
||||
import * as zod from 'zod';
|
||||
import {
|
||||
decodeCreatedAtAndUUIDIdBasedCursor,
|
||||
encodeCreatedAtAndUUIDIdBasedCursor,
|
||||
} from '@hive/storage';
|
||||
import type {
|
||||
InsightsFilterData,
|
||||
SavedFilter,
|
||||
SavedFilterVisibility,
|
||||
} from '../../../shared/entities';
|
||||
import { PG_POOL_CONFIG } from '../../shared/providers/pg-pool';
|
||||
|
||||
const SavedFilterModel = zod.object({
|
||||
id: zod.string(),
|
||||
projectId: zod.string(),
|
||||
createdByUserId: zod.string(),
|
||||
updatedByUserId: zod.string().nullable(),
|
||||
name: zod.string(),
|
||||
description: zod.string().nullable(),
|
||||
filters: zod.object({
|
||||
operationHashes: zod.array(zod.string()),
|
||||
clientFilters: zod.array(
|
||||
zod.object({
|
||||
name: zod.string(),
|
||||
versions: zod.array(zod.string()).nullable(),
|
||||
}),
|
||||
),
|
||||
dateRange: zod
|
||||
.object({
|
||||
from: zod.string(),
|
||||
to: zod.string(),
|
||||
})
|
||||
.nullable(),
|
||||
}),
|
||||
visibility: zod.enum(['private', 'shared']),
|
||||
viewsCount: zod.number(),
|
||||
createdAt: zod.string(),
|
||||
updatedAt: zod.string(),
|
||||
});
|
||||
|
||||
@Injectable({
|
||||
scope: Scope.Operation,
|
||||
})
|
||||
export class SavedFiltersStorage {
|
||||
constructor(@Inject(PG_POOL_CONFIG) private pool: DatabasePool) {}
|
||||
|
||||
async getSavedFilter(args: { id: string }): Promise<SavedFilter | null> {
|
||||
const result = await this.pool.maybeOne(sql`/* getSavedFilter */
|
||||
SELECT
|
||||
"id"
|
||||
, "project_id" as "projectId"
|
||||
, "created_by_user_id" as "createdByUserId"
|
||||
, "updated_by_user_id" as "updatedByUserId"
|
||||
, "name"
|
||||
, "description"
|
||||
, "filters"
|
||||
, "visibility"
|
||||
, "views_count" as "viewsCount"
|
||||
, to_json("created_at") as "createdAt"
|
||||
, to_json("updated_at") as "updatedAt"
|
||||
FROM
|
||||
"saved_filters"
|
||||
WHERE
|
||||
"id" = ${args.id}
|
||||
`);
|
||||
|
||||
if (result === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return SavedFilterModel.parse(result) as SavedFilter;
|
||||
}
|
||||
|
||||
async getPaginatedSavedFiltersForProject(args: {
|
||||
projectId: string;
|
||||
userId: string;
|
||||
visibility: SavedFilterVisibility | null;
|
||||
search: string | null;
|
||||
first: number;
|
||||
cursor: string | null;
|
||||
}) {
|
||||
let cursor: null | {
|
||||
createdAt: string;
|
||||
id: string;
|
||||
} = null;
|
||||
|
||||
const limit = args.first ? (args.first > 0 ? Math.min(args.first, 50) : 50) : 50;
|
||||
|
||||
if (args.cursor) {
|
||||
cursor = decodeCreatedAtAndUUIDIdBasedCursor(args.cursor);
|
||||
}
|
||||
|
||||
// Build visibility condition based on requested filter
|
||||
// When a specific visibility is requested, we can use a simpler condition
|
||||
// that allows better index usage than OR conditions
|
||||
const visibilityCondition =
|
||||
args.visibility === 'shared'
|
||||
? sql`"visibility" = 'shared'`
|
||||
: args.visibility === 'private'
|
||||
? sql`"visibility" = 'private' AND "created_by_user_id" = ${args.userId}`
|
||||
: sql`(
|
||||
"visibility" = 'shared'
|
||||
OR ("visibility" = 'private' AND "created_by_user_id" = ${args.userId})
|
||||
)`;
|
||||
|
||||
const result = await this.pool.any(sql`/* getPaginatedSavedFiltersForProject */
|
||||
SELECT
|
||||
"id"
|
||||
, "project_id" as "projectId"
|
||||
, "created_by_user_id" as "createdByUserId"
|
||||
, "updated_by_user_id" as "updatedByUserId"
|
||||
, "name"
|
||||
, "description"
|
||||
, "filters"
|
||||
, "visibility"
|
||||
, "views_count" as "viewsCount"
|
||||
, to_json("created_at") as "createdAt"
|
||||
, to_json("updated_at") as "updatedAt"
|
||||
FROM
|
||||
"saved_filters"
|
||||
WHERE
|
||||
"project_id" = ${args.projectId}
|
||||
AND ${visibilityCondition}
|
||||
${
|
||||
args.search
|
||||
? sql`
|
||||
AND (
|
||||
"name" ILIKE ${'%' + args.search + '%'}
|
||||
OR "description" ILIKE ${'%' + args.search + '%'}
|
||||
)
|
||||
`
|
||||
: sql``
|
||||
}
|
||||
${
|
||||
cursor
|
||||
? sql`
|
||||
AND (
|
||||
(
|
||||
"created_at" = ${cursor.createdAt}
|
||||
AND "id" < ${cursor.id}
|
||||
)
|
||||
OR "created_at" < ${cursor.createdAt}
|
||||
)
|
||||
`
|
||||
: sql``
|
||||
}
|
||||
ORDER BY
|
||||
"project_id" ASC
|
||||
, "created_at" DESC
|
||||
, "id" DESC
|
||||
LIMIT ${limit + 1}
|
||||
`);
|
||||
|
||||
let items = result.map(row => {
|
||||
const node = SavedFilterModel.parse(row) as SavedFilter;
|
||||
|
||||
return {
|
||||
node,
|
||||
get cursor() {
|
||||
return encodeCreatedAtAndUUIDIdBasedCursor(node);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const hasNextPage = items.length > limit;
|
||||
|
||||
items = items.slice(0, limit);
|
||||
|
||||
return {
|
||||
edges: items,
|
||||
pageInfo: {
|
||||
hasNextPage,
|
||||
hasPreviousPage: cursor !== null,
|
||||
get endCursor() {
|
||||
return items[items.length - 1]?.cursor ?? '';
|
||||
},
|
||||
get startCursor() {
|
||||
return items[0]?.cursor ?? '';
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async createSavedFilter(args: {
|
||||
projectId: string;
|
||||
createdByUserId: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
filters: InsightsFilterData;
|
||||
visibility: SavedFilterVisibility;
|
||||
}): Promise<SavedFilter> {
|
||||
const result = await this.pool.one(sql`/* createSavedFilter */
|
||||
INSERT INTO "saved_filters" (
|
||||
"project_id"
|
||||
, "created_by_user_id"
|
||||
, "name"
|
||||
, "description"
|
||||
, "filters"
|
||||
, "visibility"
|
||||
)
|
||||
VALUES (
|
||||
${args.projectId}
|
||||
, ${args.createdByUserId}
|
||||
, ${args.name}
|
||||
, ${args.description}
|
||||
, ${JSON.stringify(args.filters)}
|
||||
, ${args.visibility}
|
||||
)
|
||||
RETURNING
|
||||
"id"
|
||||
, "project_id" as "projectId"
|
||||
, "created_by_user_id" as "createdByUserId"
|
||||
, "updated_by_user_id" as "updatedByUserId"
|
||||
, "name"
|
||||
, "description"
|
||||
, "filters"
|
||||
, "visibility"
|
||||
, "views_count" as "viewsCount"
|
||||
, to_json("created_at") as "createdAt"
|
||||
, to_json("updated_at") as "updatedAt"
|
||||
`);
|
||||
|
||||
return SavedFilterModel.parse(result) as SavedFilter;
|
||||
}
|
||||
|
||||
async updateSavedFilter(args: {
|
||||
id: string;
|
||||
updatedByUserId: string;
|
||||
name: string | null;
|
||||
description: string | null;
|
||||
filters: InsightsFilterData | null;
|
||||
visibility: SavedFilterVisibility | null;
|
||||
}): Promise<SavedFilter | null> {
|
||||
const result = await this.pool.maybeOne(sql`/* updateSavedFilter */
|
||||
UPDATE
|
||||
"saved_filters"
|
||||
SET
|
||||
"name" = COALESCE(${args.name}, "name")
|
||||
, "description" = COALESCE(${args.description}, "description")
|
||||
, "filters" = COALESCE(${args.filters ? JSON.stringify(args.filters) : null}, "filters")
|
||||
, "visibility" = COALESCE(${args.visibility}, "visibility")
|
||||
, "updated_by_user_id" = ${args.updatedByUserId}
|
||||
, "updated_at" = NOW()
|
||||
WHERE
|
||||
"id" = ${args.id}
|
||||
RETURNING
|
||||
"id"
|
||||
, "project_id" as "projectId"
|
||||
, "created_by_user_id" as "createdByUserId"
|
||||
, "updated_by_user_id" as "updatedByUserId"
|
||||
, "name"
|
||||
, "description"
|
||||
, "filters"
|
||||
, "visibility"
|
||||
, "views_count" as "viewsCount"
|
||||
, to_json("created_at") as "createdAt"
|
||||
, to_json("updated_at") as "updatedAt"
|
||||
`);
|
||||
|
||||
if (result === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return SavedFilterModel.parse(result) as SavedFilter;
|
||||
}
|
||||
|
||||
async deleteSavedFilter(args: { id: string }): Promise<string | null> {
|
||||
const result = await this.pool.maybeOneFirst(sql`/* deleteSavedFilter */
|
||||
DELETE
|
||||
FROM
|
||||
"saved_filters"
|
||||
WHERE
|
||||
"id" = ${args.id}
|
||||
RETURNING
|
||||
"id"
|
||||
`);
|
||||
|
||||
if (result == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return zod.string().parse(result);
|
||||
}
|
||||
|
||||
async incrementSavedFilterViews(args: { id: string }): Promise<void> {
|
||||
await this.pool.query(sql`/* incrementSavedFilterViews */
|
||||
UPDATE
|
||||
"saved_filters"
|
||||
SET
|
||||
"views_count" = "views_count" + 1
|
||||
WHERE
|
||||
"id" = ${args.id}
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,536 @@
|
|||
import { Injectable, Scope } from 'graphql-modules';
|
||||
import * as zod from 'zod';
|
||||
import type { TargetReferenceInput } from '../../../__generated__/types';
|
||||
import type {
|
||||
InsightsFilterData,
|
||||
SavedFilter,
|
||||
SavedFilterVisibility,
|
||||
Target,
|
||||
} from '../../../shared/entities';
|
||||
import { isUUID } from '../../../shared/is-uuid';
|
||||
import { AuditLogRecorder } from '../../audit-logs/providers/audit-log-recorder';
|
||||
import { Session } from '../../auth/lib/authz';
|
||||
import { IdTranslator } from '../../shared/providers/id-translator';
|
||||
import { Logger } from '../../shared/providers/logger';
|
||||
import { SavedFiltersStorage } from './saved-filters-storage';
|
||||
|
||||
const InsightsFilterConfigurationModel = zod.object({
|
||||
// Use nullish() + transform because the resolver may pass null instead of undefined
|
||||
operationHashes: zod
|
||||
.array(zod.string())
|
||||
.max(100)
|
||||
.nullish()
|
||||
.transform(v => v ?? []),
|
||||
clientFilters: zod
|
||||
.array(
|
||||
zod.object({
|
||||
name: zod.string().min(1).max(100),
|
||||
versions: zod.array(zod.string()).max(100).nullable().optional(),
|
||||
}),
|
||||
)
|
||||
.max(50)
|
||||
.nullish()
|
||||
.transform(v => v ?? []),
|
||||
dateRange: zod
|
||||
.object({
|
||||
from: zod.string().min(1).max(100),
|
||||
to: zod.string().min(1).max(100),
|
||||
})
|
||||
.nullish()
|
||||
.transform(v => v ?? null),
|
||||
});
|
||||
|
||||
// Transform GraphQL uppercase enum values to lowercase for database storage
|
||||
const visibilityEnum = zod
|
||||
.enum(['PRIVATE', 'SHARED'])
|
||||
.transform(v => v.toLowerCase() as 'private' | 'shared');
|
||||
|
||||
const CreateSavedFilterInputModel = zod.object({
|
||||
name: zod.string().min(1).max(100),
|
||||
description: zod.string().max(500).nullable().optional(),
|
||||
visibility: visibilityEnum,
|
||||
insightsFilter: InsightsFilterConfigurationModel.nullable().optional(),
|
||||
});
|
||||
|
||||
const UpdateSavedFilterInputModel = zod.object({
|
||||
name: zod.string().min(1).max(100).nullish(),
|
||||
description: zod.string().max(500).nullable().optional(),
|
||||
// nullish() because resolver passes null when visibility is not provided
|
||||
visibility: visibilityEnum.nullish(),
|
||||
insightsFilter: InsightsFilterConfigurationModel.nullable().optional(),
|
||||
});
|
||||
|
||||
@Injectable({
|
||||
global: true,
|
||||
scope: Scope.Operation,
|
||||
})
|
||||
export class SavedFiltersProvider {
|
||||
private logger: Logger;
|
||||
|
||||
constructor(
|
||||
logger: Logger,
|
||||
private savedFiltersStorage: SavedFiltersStorage,
|
||||
private session: Session,
|
||||
private idTranslator: IdTranslator,
|
||||
private auditLog: AuditLogRecorder,
|
||||
) {
|
||||
this.logger = logger.child({ source: 'SavedFiltersProvider' });
|
||||
}
|
||||
|
||||
async getSavedFilter(target: Target, filterId: string): Promise<SavedFilter | null> {
|
||||
this.logger.debug(
|
||||
'Fetching saved filter (filterId=%s, projectId=%s)',
|
||||
filterId,
|
||||
target.projectId,
|
||||
);
|
||||
await this.session.assertPerformAction({
|
||||
action: 'project:describe',
|
||||
organizationId: target.orgId,
|
||||
params: {
|
||||
organizationId: target.orgId,
|
||||
projectId: target.projectId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!isUUID(filterId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const filter = await this.savedFiltersStorage.getSavedFilter({ id: filterId });
|
||||
|
||||
if (!filter || filter.projectId !== target.projectId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check visibility: private filters are only visible to the creator
|
||||
const currentUser = await this.session.getViewer();
|
||||
if (filter.visibility === 'private' && filter.createdByUserId !== currentUser.id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return filter;
|
||||
}
|
||||
|
||||
async getSavedFilters(
|
||||
target: Target,
|
||||
first: number,
|
||||
cursor: string | null,
|
||||
visibility: SavedFilterVisibility | null,
|
||||
search: string | null,
|
||||
) {
|
||||
this.logger.debug(
|
||||
'Listing saved filters (projectId=%s, visibility=%s, search=%s)',
|
||||
target.projectId,
|
||||
visibility,
|
||||
search,
|
||||
);
|
||||
await this.session.assertPerformAction({
|
||||
action: 'project:describe',
|
||||
organizationId: target.orgId,
|
||||
params: {
|
||||
organizationId: target.orgId,
|
||||
projectId: target.projectId,
|
||||
},
|
||||
});
|
||||
|
||||
const currentUser = await this.session.getViewer();
|
||||
|
||||
return this.savedFiltersStorage.getPaginatedSavedFiltersForProject({
|
||||
projectId: target.projectId,
|
||||
userId: currentUser.id,
|
||||
visibility,
|
||||
search,
|
||||
first,
|
||||
cursor,
|
||||
});
|
||||
}
|
||||
|
||||
async createSavedFilter(
|
||||
target: TargetReferenceInput,
|
||||
input: {
|
||||
name: string;
|
||||
description: string | null;
|
||||
visibility: string;
|
||||
insightsFilter?: {
|
||||
operationHashes?: string[] | null;
|
||||
clientFilters?: Array<{ name: string; versions?: string[] | null }> | null;
|
||||
dateRange?: { from: string; to: string } | null;
|
||||
} | null;
|
||||
},
|
||||
): Promise<{ type: 'success'; savedFilter: SavedFilter } | { type: 'error'; message: string }> {
|
||||
this.logger.info(
|
||||
'Creating saved filter (name=%s, visibility=%s)',
|
||||
input.name,
|
||||
input.visibility,
|
||||
);
|
||||
|
||||
const resolved = await this.idTranslator.resolveTargetReference({ reference: target });
|
||||
if (resolved === null) {
|
||||
return { type: 'error', message: 'Target not found' };
|
||||
}
|
||||
const { organizationId, projectId } = resolved;
|
||||
|
||||
await this.session.assertPerformAction({
|
||||
action: 'project:describe',
|
||||
organizationId,
|
||||
params: {
|
||||
organizationId,
|
||||
projectId,
|
||||
},
|
||||
});
|
||||
|
||||
const validationResult = CreateSavedFilterInputModel.safeParse(input);
|
||||
if (!validationResult.success) {
|
||||
return {
|
||||
type: 'error',
|
||||
message: validationResult.error.errors[0].message,
|
||||
};
|
||||
}
|
||||
|
||||
const data = validationResult.data;
|
||||
|
||||
// Shared filters require the sharedSavedFilter:modify permission
|
||||
if (data.visibility === 'shared') {
|
||||
await this.session.assertPerformAction({
|
||||
action: 'sharedSavedFilter:modify',
|
||||
organizationId,
|
||||
params: {
|
||||
organizationId,
|
||||
projectId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const currentUser = await this.session.getViewer();
|
||||
|
||||
const filters: InsightsFilterData = {
|
||||
operationHashes: data.insightsFilter?.operationHashes ?? [],
|
||||
clientFilters:
|
||||
data.insightsFilter?.clientFilters?.map(cf => ({
|
||||
name: cf.name,
|
||||
versions: cf.versions ?? null,
|
||||
})) ?? [],
|
||||
dateRange: data.insightsFilter?.dateRange ?? null,
|
||||
};
|
||||
|
||||
const savedFilter = await this.savedFiltersStorage.createSavedFilter({
|
||||
projectId,
|
||||
createdByUserId: currentUser.id,
|
||||
name: data.name,
|
||||
description: data.description ?? null,
|
||||
filters,
|
||||
visibility: data.visibility as SavedFilterVisibility,
|
||||
});
|
||||
|
||||
await this.auditLog.record({
|
||||
eventType: 'SAVED_FILTER_CREATED',
|
||||
organizationId,
|
||||
metadata: {
|
||||
filterId: savedFilter.id,
|
||||
filterName: savedFilter.name,
|
||||
visibility: savedFilter.visibility,
|
||||
projectId,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
type: 'success',
|
||||
savedFilter,
|
||||
};
|
||||
}
|
||||
|
||||
async updateSavedFilter(
|
||||
target: TargetReferenceInput,
|
||||
filterId: string,
|
||||
input: {
|
||||
name?: string | null;
|
||||
description?: string | null;
|
||||
visibility?: string | null;
|
||||
insightsFilter?: {
|
||||
operationHashes?: string[] | null;
|
||||
clientFilters?: Array<{ name: string; versions?: string[] | null }> | null;
|
||||
dateRange?: { from: string; to: string } | null;
|
||||
} | null;
|
||||
},
|
||||
): Promise<{ type: 'success'; savedFilter: SavedFilter } | { type: 'error'; message: string }> {
|
||||
this.logger.info('Updating saved filter (filterId=%s)', filterId);
|
||||
|
||||
const resolved = await this.idTranslator.resolveTargetReference({ reference: target });
|
||||
if (resolved === null) {
|
||||
return { type: 'error', message: 'Target not found' };
|
||||
}
|
||||
const { organizationId, projectId } = resolved;
|
||||
|
||||
await this.session.assertPerformAction({
|
||||
action: 'project:describe',
|
||||
organizationId,
|
||||
params: {
|
||||
organizationId,
|
||||
projectId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!isUUID(filterId)) {
|
||||
return {
|
||||
type: 'error',
|
||||
message: 'Saved filter not found',
|
||||
};
|
||||
}
|
||||
|
||||
const existingFilter = await this.savedFiltersStorage.getSavedFilter({ id: filterId });
|
||||
|
||||
if (!existingFilter || existingFilter.projectId !== projectId) {
|
||||
return {
|
||||
type: 'error',
|
||||
message: 'Saved filter not found',
|
||||
};
|
||||
}
|
||||
|
||||
const currentUser = await this.session.getViewer();
|
||||
|
||||
// Check if user can update this filter
|
||||
if (!this.canUserModifyFilter(existingFilter, currentUser.id)) {
|
||||
return {
|
||||
type: 'error',
|
||||
message: 'You do not have permission to update this filter',
|
||||
};
|
||||
}
|
||||
|
||||
const validationResult = UpdateSavedFilterInputModel.safeParse(input);
|
||||
if (!validationResult.success) {
|
||||
return {
|
||||
type: 'error',
|
||||
message: validationResult.error.errors[0].message,
|
||||
};
|
||||
}
|
||||
|
||||
const data = validationResult.data;
|
||||
|
||||
// Shared filters (or changing visibility to shared) require the sharedSavedFilter:modify permission
|
||||
const newVisibility = (data.visibility as SavedFilterVisibility) ?? null;
|
||||
if (existingFilter.visibility === 'shared' || newVisibility === 'shared') {
|
||||
await this.session.assertPerformAction({
|
||||
action: 'sharedSavedFilter:modify',
|
||||
organizationId,
|
||||
params: {
|
||||
organizationId,
|
||||
projectId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const filters: InsightsFilterData | null = data.insightsFilter
|
||||
? {
|
||||
operationHashes: data.insightsFilter.operationHashes ?? [],
|
||||
clientFilters:
|
||||
data.insightsFilter.clientFilters?.map(cf => ({
|
||||
name: cf.name,
|
||||
versions: cf.versions ?? null,
|
||||
})) ?? [],
|
||||
dateRange: data.insightsFilter.dateRange ?? null,
|
||||
}
|
||||
: null;
|
||||
|
||||
const savedFilter = await this.savedFiltersStorage.updateSavedFilter({
|
||||
id: filterId,
|
||||
updatedByUserId: currentUser.id,
|
||||
name: data.name ?? null,
|
||||
description: data.description ?? null,
|
||||
filters,
|
||||
visibility: (data.visibility as SavedFilterVisibility) ?? null,
|
||||
});
|
||||
|
||||
if (!savedFilter) {
|
||||
return {
|
||||
type: 'error',
|
||||
message: 'Failed to update saved filter',
|
||||
};
|
||||
}
|
||||
|
||||
await this.auditLog.record({
|
||||
eventType: 'SAVED_FILTER_UPDATED',
|
||||
organizationId,
|
||||
metadata: {
|
||||
filterId: savedFilter.id,
|
||||
filterName: savedFilter.name,
|
||||
updatedFields: JSON.stringify({
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
visibility: data.visibility,
|
||||
filters: filters ? true : false,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
type: 'success',
|
||||
savedFilter,
|
||||
};
|
||||
}
|
||||
|
||||
async deleteSavedFilter(
|
||||
target: TargetReferenceInput,
|
||||
filterId: string,
|
||||
): Promise<{ type: 'success'; deletedId: string } | { type: 'error'; message: string }> {
|
||||
this.logger.info('Deleting saved filter (filterId=%s)', filterId);
|
||||
|
||||
const resolved = await this.idTranslator.resolveTargetReference({ reference: target });
|
||||
if (resolved === null) {
|
||||
return { type: 'error', message: 'Target not found' };
|
||||
}
|
||||
const { organizationId, projectId } = resolved;
|
||||
|
||||
await this.session.assertPerformAction({
|
||||
action: 'project:describe',
|
||||
organizationId,
|
||||
params: {
|
||||
organizationId,
|
||||
projectId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!isUUID(filterId)) {
|
||||
return {
|
||||
type: 'error',
|
||||
message: 'Saved filter not found',
|
||||
};
|
||||
}
|
||||
|
||||
const existingFilter = await this.savedFiltersStorage.getSavedFilter({ id: filterId });
|
||||
|
||||
if (!existingFilter || existingFilter.projectId !== projectId) {
|
||||
return {
|
||||
type: 'error',
|
||||
message: 'Saved filter not found',
|
||||
};
|
||||
}
|
||||
|
||||
// Shared filters require the sharedSavedFilter:modify permission
|
||||
if (existingFilter.visibility === 'shared') {
|
||||
await this.session.assertPerformAction({
|
||||
action: 'sharedSavedFilter:modify',
|
||||
organizationId,
|
||||
params: {
|
||||
organizationId,
|
||||
projectId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const currentUser = await this.session.getViewer();
|
||||
|
||||
// Check if user can delete this filter
|
||||
if (!this.canUserModifyFilter(existingFilter, currentUser.id)) {
|
||||
return {
|
||||
type: 'error',
|
||||
message: 'You do not have permission to delete this filter',
|
||||
};
|
||||
}
|
||||
|
||||
const deletedId = await this.savedFiltersStorage.deleteSavedFilter({ id: filterId });
|
||||
|
||||
if (!deletedId) {
|
||||
return {
|
||||
type: 'error',
|
||||
message: 'Failed to delete saved filter',
|
||||
};
|
||||
}
|
||||
|
||||
await this.auditLog.record({
|
||||
eventType: 'SAVED_FILTER_DELETED',
|
||||
organizationId,
|
||||
metadata: {
|
||||
filterId: existingFilter.id,
|
||||
filterName: existingFilter.name,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
type: 'success',
|
||||
deletedId,
|
||||
};
|
||||
}
|
||||
|
||||
async trackView(
|
||||
target: TargetReferenceInput,
|
||||
filterId: string,
|
||||
): Promise<{ type: 'success'; savedFilter: SavedFilter } | { type: 'error'; message: string }> {
|
||||
this.logger.debug('Tracking saved filter view (filterId=%s)', filterId);
|
||||
|
||||
const resolved = await this.idTranslator.resolveTargetReference({ reference: target });
|
||||
if (resolved === null) {
|
||||
return { type: 'error', message: 'Target not found' };
|
||||
}
|
||||
const { organizationId, projectId } = resolved;
|
||||
|
||||
await this.session.assertPerformAction({
|
||||
action: 'project:describe',
|
||||
organizationId,
|
||||
params: {
|
||||
organizationId,
|
||||
projectId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!isUUID(filterId)) {
|
||||
return {
|
||||
type: 'error',
|
||||
message: 'Saved filter not found',
|
||||
};
|
||||
}
|
||||
|
||||
const existingFilter = await this.savedFiltersStorage.getSavedFilter({ id: filterId });
|
||||
|
||||
if (!existingFilter || existingFilter.projectId !== projectId) {
|
||||
return {
|
||||
type: 'error',
|
||||
message: 'Saved filter not found',
|
||||
};
|
||||
}
|
||||
|
||||
const currentUser = await this.session.getViewer();
|
||||
|
||||
// Check visibility
|
||||
if (
|
||||
existingFilter.visibility === 'private' &&
|
||||
existingFilter.createdByUserId !== currentUser.id
|
||||
) {
|
||||
return {
|
||||
type: 'error',
|
||||
message: 'Saved filter not found',
|
||||
};
|
||||
}
|
||||
|
||||
await this.savedFiltersStorage.incrementSavedFilterViews({ id: filterId });
|
||||
|
||||
// Fetch the updated filter
|
||||
const savedFilter = await this.savedFiltersStorage.getSavedFilter({ id: filterId });
|
||||
|
||||
if (!savedFilter) {
|
||||
return {
|
||||
type: 'error',
|
||||
message: 'Failed to track view',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'success',
|
||||
savedFilter,
|
||||
};
|
||||
}
|
||||
|
||||
canUserModifyFilter(filter: SavedFilter, userId: string): boolean {
|
||||
// Private filters can only be modified by the creator
|
||||
if (filter.visibility === 'private') {
|
||||
return filter.createdByUserId === userId;
|
||||
}
|
||||
// Shared filters can be modified by anyone with project:modify permission
|
||||
// The permission check is done at the method level
|
||||
return true;
|
||||
}
|
||||
|
||||
canUserDeleteFilter(filter: SavedFilter, userId: string): boolean {
|
||||
// Same logic as canUserModifyFilter
|
||||
return this.canUserModifyFilter(filter, userId);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
import { SavedFiltersProvider } from '../../providers/saved-filters.provider';
|
||||
import type { MutationResolvers } from './../../../../__generated__/types';
|
||||
|
||||
export const createSavedFilter: NonNullable<MutationResolvers['createSavedFilter']> = async (
|
||||
_parent,
|
||||
{ input },
|
||||
{ injector },
|
||||
) => {
|
||||
const result = await injector.get(SavedFiltersProvider).createSavedFilter(input.target, {
|
||||
name: input.name,
|
||||
description: input.description ?? null,
|
||||
visibility: input.visibility,
|
||||
insightsFilter: input.insightsFilter
|
||||
? {
|
||||
operationHashes: input.insightsFilter.operationHashes
|
||||
? [...input.insightsFilter.operationHashes]
|
||||
: null,
|
||||
clientFilters:
|
||||
input.insightsFilter.clientFilters?.map(cf => ({
|
||||
name: cf.name,
|
||||
versions: cf.versions ? [...cf.versions] : null,
|
||||
})) ?? null,
|
||||
dateRange: input.insightsFilter.dateRange
|
||||
? { from: input.insightsFilter.dateRange.from, to: input.insightsFilter.dateRange.to }
|
||||
: null,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
|
||||
if (result.type === 'error') {
|
||||
return {
|
||||
error: {
|
||||
message: result.message,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: {
|
||||
savedFilter: result.savedFilter,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
import { SavedFiltersProvider } from '../../providers/saved-filters.provider';
|
||||
import type { MutationResolvers } from './../../../../__generated__/types';
|
||||
|
||||
export const deleteSavedFilter: NonNullable<MutationResolvers['deleteSavedFilter']> = async (
|
||||
_parent,
|
||||
{ input },
|
||||
{ injector },
|
||||
) => {
|
||||
const result = await injector.get(SavedFiltersProvider).deleteSavedFilter(input.target, input.id);
|
||||
|
||||
if (result.type === 'error') {
|
||||
return {
|
||||
error: {
|
||||
message: result.message,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: {
|
||||
deletedId: result.deletedId,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
import { SavedFiltersProvider } from '../../providers/saved-filters.provider';
|
||||
import type { MutationResolvers } from './../../../../__generated__/types';
|
||||
|
||||
export const trackSavedFilterView: NonNullable<MutationResolvers['trackSavedFilterView']> = async (
|
||||
_parent,
|
||||
{ input },
|
||||
{ injector },
|
||||
) => {
|
||||
const result = await injector.get(SavedFiltersProvider).trackView(input.target, input.id);
|
||||
|
||||
if (result.type === 'error') {
|
||||
return {
|
||||
error: {
|
||||
message: result.message,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: {
|
||||
savedFilter: result.savedFilter,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
import { IdTranslator } from '../../../shared/providers/id-translator';
|
||||
import { SavedFiltersProvider } from '../../providers/saved-filters.provider';
|
||||
import type { MutationResolvers } from './../../../../__generated__/types';
|
||||
|
||||
export const updateSavedFilter: NonNullable<MutationResolvers['updateSavedFilter']> = async (
|
||||
_parent,
|
||||
{ input },
|
||||
{ injector },
|
||||
) => {
|
||||
const result = await injector
|
||||
.get(SavedFiltersProvider)
|
||||
.updateSavedFilter(input.target, input.id, {
|
||||
name: input.name ?? null,
|
||||
description: input.description,
|
||||
visibility: input.visibility ?? null,
|
||||
insightsFilter: input.insightsFilter
|
||||
? {
|
||||
operationHashes: input.insightsFilter.operationHashes
|
||||
? [...input.insightsFilter.operationHashes]
|
||||
: null,
|
||||
clientFilters:
|
||||
input.insightsFilter.clientFilters?.map(cf => ({
|
||||
name: cf.name,
|
||||
versions: cf.versions ? [...cf.versions] : null,
|
||||
})) ?? null,
|
||||
dateRange: input.insightsFilter.dateRange
|
||||
? {
|
||||
from: input.insightsFilter.dateRange.from,
|
||||
to: input.insightsFilter.dateRange.to,
|
||||
}
|
||||
: null,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
|
||||
if (result.type === 'error') {
|
||||
return {
|
||||
error: {
|
||||
message: result.message,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Attach org context so viewerCanUpdate/viewerCanDelete can check permissions
|
||||
const resolved = await injector
|
||||
.get(IdTranslator)
|
||||
.resolveTargetReference({ reference: input.target });
|
||||
|
||||
return {
|
||||
ok: {
|
||||
savedFilter: {
|
||||
...result.savedFilter,
|
||||
orgId: resolved?.organizationId,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
import { Storage } from '../../shared/providers/storage';
|
||||
import { SavedFiltersProvider } from '../providers/saved-filters.provider';
|
||||
import type { SavedFilterResolvers } from './../../../__generated__/types';
|
||||
|
||||
export const SavedFilter: SavedFilterResolvers = {
|
||||
id: filter => filter.id,
|
||||
name: filter => filter.name,
|
||||
description: filter => filter.description,
|
||||
filters: filter => {
|
||||
const filters = filter.filters as {
|
||||
operationHashes?: string[];
|
||||
clientFilters?: Array<{ name: string; versions?: string[] | null }>;
|
||||
dateRange?: { from: string; to: string } | null;
|
||||
};
|
||||
return {
|
||||
operationHashes: filters.operationHashes ?? [],
|
||||
clientFilters:
|
||||
filters.clientFilters?.map(cf => ({
|
||||
name: cf.name,
|
||||
versions: cf.versions ?? null,
|
||||
})) ?? [],
|
||||
dateRange: filters.dateRange ?? null,
|
||||
};
|
||||
},
|
||||
visibility: filter => (filter.visibility === 'private' ? 'PRIVATE' : 'SHARED'),
|
||||
viewsCount: filter => filter.viewsCount,
|
||||
createdAt: filter => filter.createdAt,
|
||||
updatedAt: filter => filter.updatedAt,
|
||||
createdBy: async (filter, _args, { injector }) => {
|
||||
return injector.get(Storage).getUserById({ id: filter.createdByUserId });
|
||||
},
|
||||
updatedBy: async (filter, _args, { injector }) => {
|
||||
if (!filter.updatedByUserId) {
|
||||
return null;
|
||||
}
|
||||
return injector.get(Storage).getUserById({ id: filter.updatedByUserId });
|
||||
},
|
||||
viewerCanUpdate: async (filter, _args, { injector, session }) => {
|
||||
// For shared filters, check sharedSavedFilter:modify permission
|
||||
if (filter.visibility === 'shared' && filter.orgId) {
|
||||
const canModifyShared = await session.canPerformAction({
|
||||
action: 'sharedSavedFilter:modify',
|
||||
organizationId: filter.orgId,
|
||||
params: { organizationId: filter.orgId, projectId: filter.projectId },
|
||||
});
|
||||
if (!canModifyShared) return false;
|
||||
}
|
||||
// Check ownership (private filters can only be modified by creator)
|
||||
const currentUser = await session.getViewer();
|
||||
return injector.get(SavedFiltersProvider).canUserModifyFilter(filter, currentUser.id);
|
||||
},
|
||||
viewerCanDelete: async (filter, _args, { injector, session }) => {
|
||||
// For shared filters, check sharedSavedFilter:modify permission
|
||||
if (filter.visibility === 'shared' && filter.orgId) {
|
||||
const canModifyShared = await session.canPerformAction({
|
||||
action: 'sharedSavedFilter:modify',
|
||||
organizationId: filter.orgId,
|
||||
params: { organizationId: filter.orgId, projectId: filter.projectId },
|
||||
});
|
||||
if (!canModifyShared) return false;
|
||||
}
|
||||
// Check ownership (private filters can only be modified by creator)
|
||||
const currentUser = await session.getViewer();
|
||||
return injector.get(SavedFiltersProvider).canUserDeleteFilter(filter, currentUser.id);
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import type { SavedFilterConnectionResolvers } from './../../../__generated__/types';
|
||||
|
||||
export const SavedFilterConnection: SavedFilterConnectionResolvers = {
|
||||
edges: connection => connection.edges,
|
||||
pageInfo: connection => connection.pageInfo,
|
||||
};
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
import type { SavedFilterVisibilityType as GqlVisibility } from '../../../__generated__/types';
|
||||
import type { SavedFilterVisibility } from '../../../shared/entities';
|
||||
import { SavedFiltersProvider } from '../providers/saved-filters.provider';
|
||||
import type { TargetResolvers } from './../../../__generated__/types';
|
||||
|
||||
function mapVisibility(visibility: GqlVisibility | null): SavedFilterVisibility | null {
|
||||
if (visibility === null) return null;
|
||||
return visibility === 'PRIVATE' ? 'private' : 'shared';
|
||||
}
|
||||
|
||||
export const Target: Pick<
|
||||
TargetResolvers,
|
||||
'savedFilter' | 'savedFilters' | 'viewerCanCreateSavedFilter' | 'viewerCanShareSavedFilter'
|
||||
> = {
|
||||
savedFilter: async (target, args, { injector }) => {
|
||||
const filter = await injector.get(SavedFiltersProvider).getSavedFilter(target, args.id);
|
||||
return filter ? { ...filter, orgId: target.orgId } : null;
|
||||
},
|
||||
savedFilters: async (target, args, { injector }) => {
|
||||
const result = await injector
|
||||
.get(SavedFiltersProvider)
|
||||
.getSavedFilters(
|
||||
target,
|
||||
args.first,
|
||||
args.after ?? null,
|
||||
mapVisibility(args.visibility ?? null),
|
||||
args.search ?? null,
|
||||
);
|
||||
return {
|
||||
...result,
|
||||
edges: result.edges.map(edge => ({
|
||||
...edge,
|
||||
node: { ...edge.node, orgId: target.orgId },
|
||||
})),
|
||||
};
|
||||
},
|
||||
viewerCanCreateSavedFilter: (target, _args, { session }) => {
|
||||
return session.canPerformAction({
|
||||
action: 'project:describe',
|
||||
organizationId: target.orgId,
|
||||
params: {
|
||||
organizationId: target.orgId,
|
||||
projectId: target.projectId,
|
||||
},
|
||||
});
|
||||
},
|
||||
viewerCanShareSavedFilter: (target, _args, { session }) => {
|
||||
return session.canPerformAction({
|
||||
action: 'sharedSavedFilter:modify',
|
||||
organizationId: target.orgId,
|
||||
params: {
|
||||
organizationId: target.orgId,
|
||||
projectId: target.projectId,
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
@ -297,6 +297,41 @@ export type PaginatedDocumentCollectionOperations = Readonly<{
|
|||
}>;
|
||||
}>;
|
||||
|
||||
export type SavedFilterVisibility = 'private' | 'shared';
|
||||
|
||||
export interface InsightsFilterData {
|
||||
operationHashes: string[];
|
||||
clientFilters: Array<{ name: string; versions: string[] | null }>;
|
||||
dateRange: { from: string; to: string } | null;
|
||||
}
|
||||
|
||||
export interface SavedFilter {
|
||||
id: string;
|
||||
projectId: string;
|
||||
createdByUserId: string;
|
||||
updatedByUserId: string | null;
|
||||
name: string;
|
||||
description: string | null;
|
||||
filters: InsightsFilterData;
|
||||
visibility: SavedFilterVisibility;
|
||||
viewsCount: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export type PaginatedSavedFilters = Readonly<{
|
||||
edges: ReadonlyArray<{
|
||||
node: SavedFilter;
|
||||
cursor: string;
|
||||
}>;
|
||||
pageInfo: Readonly<{
|
||||
hasNextPage: boolean;
|
||||
hasPreviousPage: boolean;
|
||||
startCursor: string;
|
||||
endCursor: string;
|
||||
}>;
|
||||
}>;
|
||||
|
||||
export interface Project {
|
||||
id: string;
|
||||
slug: string;
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
export type alert_channel_type = 'MSTEAMS_WEBHOOK' | 'SLACK' | 'WEBHOOK';
|
||||
export type alert_type = 'SCHEMA_CHANGE_NOTIFICATIONS';
|
||||
export type breaking_change_formula = 'PERCENTAGE' | 'REQUEST_COUNT';
|
||||
export type saved_filter_visibility = 'private' | 'shared';
|
||||
export type schema_policy_resource = 'ORGANIZATION' | 'PROJECT';
|
||||
export type schema_proposal_stage = 'APPROVED' | 'CLOSED' | 'DRAFT' | 'IMPLEMENTED' | 'OPEN';
|
||||
export type user_role = 'ADMIN' | 'MEMBER';
|
||||
|
|
@ -270,6 +271,20 @@ export interface projects {
|
|||
validation_url: string | null;
|
||||
}
|
||||
|
||||
export interface saved_filters {
|
||||
created_at: Date;
|
||||
created_by_user_id: string;
|
||||
description: string | null;
|
||||
filters: any;
|
||||
id: string;
|
||||
name: string;
|
||||
project_id: string;
|
||||
updated_at: Date;
|
||||
updated_by_user_id: string | null;
|
||||
views_count: number;
|
||||
visibility: saved_filter_visibility;
|
||||
}
|
||||
|
||||
export interface schema_change_approvals {
|
||||
context_id: string;
|
||||
created_at: Date;
|
||||
|
|
@ -520,6 +535,7 @@ export interface DBTables {
|
|||
organizations: organizations;
|
||||
organizations_billing: organizations_billing;
|
||||
projects: projects;
|
||||
saved_filters: saved_filters;
|
||||
schema_change_approvals: schema_change_approvals;
|
||||
schema_checks: schema_checks;
|
||||
schema_coordinate_status: schema_coordinate_status;
|
||||
|
|
|
|||
|
|
@ -4100,6 +4100,7 @@ export async function createStorage(
|
|||
|
||||
return DocumentCollectionDocumentModel.parse(result);
|
||||
},
|
||||
|
||||
async createSchemaCheck(args) {
|
||||
const result = await tracedTransaction('createSchemaCheck', pool, async trx => {
|
||||
const sdlStoreInserts: Array<Promise<unknown>> = [];
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import '../src/index.css';
|
||||
import './ladle.css';
|
||||
import { useEffect } from 'react';
|
||||
import type { GlobalProvider } from '@ladle/react';
|
||||
import { ThemeProvider, useTheme } from '../src/components/theme/theme-provider';
|
||||
|
|
|
|||
3
packages/web/app/.ladle/ladle.css
Normal file
3
packages/web/app/.ladle/ladle.css
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
.ladle-background {
|
||||
background: hsl(var(--color-neutral-2)) !important;
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@
|
|||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@base-ui/react": "^1.1.0",
|
||||
"@date-fns/utc": "2.1.1",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
|
|
@ -69,6 +70,7 @@
|
|||
"@tanstack/react-router": "1.34.9",
|
||||
"@tanstack/react-router-devtools": "^1.139.13",
|
||||
"@tanstack/react-table": "8.20.6",
|
||||
"@tanstack/react-virtual": "^3.13.18",
|
||||
"@tanstack/router-devtools": "1.34.9",
|
||||
"@tanstack/zod-adapter": "1.120.5",
|
||||
"@theguild/editor": "1.2.5",
|
||||
|
|
@ -82,7 +84,6 @@
|
|||
"@types/react-dom": "18.3.5",
|
||||
"@types/react-highlight-words": "0.20.0",
|
||||
"@types/react-virtualized-auto-sizer": "1.0.4",
|
||||
"@types/react-window": "1.8.8",
|
||||
"@urql/core": "5.0.3",
|
||||
"@urql/exchange-auth": "2.2.0",
|
||||
"@urql/exchange-graphcache": "7.1.0",
|
||||
|
|
@ -133,7 +134,6 @@
|
|||
"react-toastify": "10.0.6",
|
||||
"react-virtualized-auto-sizer": "1.0.25",
|
||||
"react-virtuoso": "4.12.3",
|
||||
"react-window": "1.8.11",
|
||||
"recharts": "2.15.1",
|
||||
"regenerator-runtime": "0.14.1",
|
||||
"snarkdown": "2.0.0",
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@ import {
|
|||
Tooltip,
|
||||
Tr,
|
||||
} from '@/components/v2';
|
||||
import { CHART_PRIMARY_COLOR } from '@/constants';
|
||||
import { env } from '@/env/frontend';
|
||||
import { DocumentType, FragmentType, graphql, useFragment } from '@/gql';
|
||||
import { theme } from '@/lib/charts';
|
||||
|
|
@ -85,7 +84,7 @@ function CollectedOperationsOverTime(props: {
|
|||
const dataRef = useRef<[string, number][]>();
|
||||
dataRef.current ||= operations.map(node => [node.date, node.count]);
|
||||
const data = dataRef.current;
|
||||
const chartStyles = useChartStyles();
|
||||
const { styles: chartStyles, colors } = useChartStyles();
|
||||
|
||||
return (
|
||||
<AutoSizer disableHeight>
|
||||
|
|
@ -117,7 +116,7 @@ function CollectedOperationsOverTime(props: {
|
|||
min: 0,
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
color: '#595959',
|
||||
color: colors.grid,
|
||||
type: 'dashed',
|
||||
},
|
||||
},
|
||||
|
|
@ -132,7 +131,7 @@ function CollectedOperationsOverTime(props: {
|
|||
name: 'Collected operations',
|
||||
showSymbol: false,
|
||||
smooth: true,
|
||||
color: CHART_PRIMARY_COLOR,
|
||||
color: colors.primary,
|
||||
areaStyle: {},
|
||||
emphasis: {
|
||||
focus: 'series',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,87 @@
|
|||
import { useState } from 'react';
|
||||
import type { Story, StoryDefault } from '@ladle/react';
|
||||
import { Checkbox } from './checkbox';
|
||||
|
||||
export default {
|
||||
title: 'Base / Checkbox',
|
||||
} satisfies StoryDefault;
|
||||
|
||||
export const Default: Story = () => {
|
||||
const [checked, setChecked] = useState(false);
|
||||
return (
|
||||
<div className="flex items-center gap-3 p-8">
|
||||
<Checkbox checked={checked} onCheckedChange={setChecked} />
|
||||
<span className="text-neutral-11 text-sm">{checked ? 'Checked' : 'Unchecked'}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Sizes: Story = () => {
|
||||
const [values, setValues] = useState({ sm: false, md: true });
|
||||
return (
|
||||
<div className="flex items-center gap-6 p-8">
|
||||
{(['sm', 'md'] as const).map(size => (
|
||||
<div key={size} className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={values[size]}
|
||||
onCheckedChange={v => setValues(prev => ({ ...prev, [size]: v }))}
|
||||
size={size}
|
||||
/>
|
||||
<span className="text-neutral-11 text-sm">{size}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Indeterminate: Story = () => {
|
||||
const [items, setItems] = useState([true, false, true]);
|
||||
|
||||
const allChecked = items.every(Boolean);
|
||||
const noneChecked = items.every(v => !v);
|
||||
const indeterminate = !allChecked && !noneChecked;
|
||||
|
||||
const toggleAll = () => {
|
||||
if (allChecked || indeterminate) {
|
||||
setItems([false, false, false]);
|
||||
} else {
|
||||
setItems([true, true, true]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 p-8">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox checked={allChecked} indeterminate={indeterminate} onCheckedChange={toggleAll} />
|
||||
<span className="text-neutral-11 text-sm font-medium">Select all</span>
|
||||
</div>
|
||||
<div className="ml-4 flex flex-col gap-2">
|
||||
{items.map((checked, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
size="sm"
|
||||
checked={checked}
|
||||
onCheckedChange={v =>
|
||||
setItems(prev => prev.map((item, idx) => (idx === i ? !!v : item)))
|
||||
}
|
||||
/>
|
||||
<span className="text-neutral-11 text-sm">Item {i + 1}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Disabled: Story = () => (
|
||||
<div className="flex items-center gap-6 p-8">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox disabled checked={false} />
|
||||
<span className="text-neutral-8 text-sm">Disabled unchecked</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox disabled checked />
|
||||
<span className="text-neutral-8 text-sm">Disabled checked</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
68
packages/web/app/src/components/base/checkbox/checkbox.tsx
Normal file
68
packages/web/app/src/components/base/checkbox/checkbox.tsx
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { Check, Minus } from 'lucide-react';
|
||||
import { Checkbox as BaseCheckbox } from '@base-ui/react/checkbox';
|
||||
|
||||
const checkboxVariants = cva(
|
||||
'inline-flex shrink-0 items-center justify-center rounded-sm border transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-accent disabled:cursor-not-allowed disabled:opacity-50',
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
sm: 'size-3.5',
|
||||
md: 'size-4.5',
|
||||
},
|
||||
variant: {
|
||||
default: [
|
||||
'border-neutral-6',
|
||||
'data-[checked]:bg-accent_30 data-[checked]:border-accent_30 data-[checked]:text-accent',
|
||||
'hover:bg-neutral-6 hover:border-accent_30',
|
||||
'data-[checked]:hover:bg-accent_10',
|
||||
'data-[indeterminate]:bg-accent_30 data-[indeterminate]:border-accent_30 data-[indeterminate]:text-accent',
|
||||
],
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'md',
|
||||
variant: 'default',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const iconSizeMap = {
|
||||
sm: 'size-2.5',
|
||||
md: 'size-3',
|
||||
} as const;
|
||||
|
||||
export function Checkbox({
|
||||
size = 'md',
|
||||
variant,
|
||||
visual,
|
||||
...props
|
||||
}: Omit<BaseCheckbox.Root.Props, 'children' | 'className'> &
|
||||
VariantProps<typeof checkboxVariants> & {
|
||||
/** When true, the checkbox is a non-interactive visual indicator (preserves hover styles, sets tabIndex: -1, aria-hidden) */
|
||||
visual?: boolean;
|
||||
}) {
|
||||
const iconClass = iconSizeMap[size ?? 'md'];
|
||||
return (
|
||||
<BaseCheckbox.Root
|
||||
className={checkboxVariants({ size, variant })}
|
||||
{...(visual
|
||||
? {
|
||||
tabIndex: -1,
|
||||
'aria-hidden': true,
|
||||
style: { cursor: 'default' as const },
|
||||
onCheckedChange: undefined,
|
||||
}
|
||||
: {})}
|
||||
{...props}
|
||||
>
|
||||
<BaseCheckbox.Indicator className="flex items-center justify-center text-current">
|
||||
{props.indeterminate ? (
|
||||
<Minus className={iconClass} strokeWidth={3} />
|
||||
) : (
|
||||
<Check className={iconClass} strokeWidth={3} />
|
||||
)}
|
||||
</BaseCheckbox.Indicator>
|
||||
</BaseCheckbox.Root>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,175 @@
|
|||
import { useCallback, useDeferredValue, useMemo, useRef, useState } from 'react';
|
||||
import { FilterListSearch } from '@/components/base/filter-dropdown/filter-list-search';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { ItemRow } from './item-row';
|
||||
import type { FilterItem, FilterSelection } from './types';
|
||||
|
||||
const ITEM_HEIGHT = 28; // h-7
|
||||
const MAX_LIST_HEIGHT = 256; // max-h-64
|
||||
|
||||
function getKey(item: FilterItem | FilterSelection): string {
|
||||
return item.id ?? item.name;
|
||||
}
|
||||
|
||||
function isItemSelected(item: FilterItem, selectedItems: FilterSelection[]): boolean {
|
||||
const key = getKey(item);
|
||||
return selectedItems.some(s => getKey(s) === key);
|
||||
}
|
||||
|
||||
function getItemSelection(
|
||||
item: FilterItem,
|
||||
selectedItems: FilterSelection[],
|
||||
): FilterSelection | null {
|
||||
const key = getKey(item);
|
||||
return selectedItems.find(s => getKey(s) === key) ?? null;
|
||||
}
|
||||
|
||||
export type FilterContentProps = {
|
||||
/** Used in the search input's aria-label */
|
||||
label: string;
|
||||
/** Available items and their sub-values */
|
||||
items: FilterItem[];
|
||||
/** Currently selected items */
|
||||
selectedItems: FilterSelection[];
|
||||
/** Called when selection changes */
|
||||
onChange: (value: FilterSelection[]) => void;
|
||||
/** Label for the sub-values (e.g. "versions", "endpoints"). Used in accessibility labels. */
|
||||
valuesLabel?: string;
|
||||
};
|
||||
|
||||
export function FilterContent({
|
||||
label,
|
||||
items,
|
||||
selectedItems,
|
||||
onChange,
|
||||
valuesLabel = 'values',
|
||||
}: FilterContentProps) {
|
||||
const [search, setSearch] = useState('');
|
||||
const deferredSearch = useDeferredValue(search);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Snapshot which items were selected when the dropdown opened.
|
||||
// This sort order is frozen — toggling items won't move them.
|
||||
// Resets naturally when the portal unmounts on close and remounts on reopen.
|
||||
const initialSelectedKeys = useRef<Set<string>>(null!);
|
||||
if (initialSelectedKeys.current === null) {
|
||||
initialSelectedKeys.current = new Set(selectedItems.map(getKey));
|
||||
}
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
const matched = items.filter(item =>
|
||||
item.name.toLowerCase().includes(deferredSearch.toLowerCase()),
|
||||
);
|
||||
return matched.sort((a, b) => {
|
||||
// Initially-selected items first
|
||||
const aSelected = initialSelectedKeys.current.has(getKey(a)) ? 0 : 1;
|
||||
const bSelected = initialSelectedKeys.current.has(getKey(b)) ? 0 : 1;
|
||||
if (aSelected !== bSelected) return aSelected - bSelected;
|
||||
// Unavailable items to the bottom within each group
|
||||
return (a.unavailable ? 1 : 0) - (b.unavailable ? 1 : 0);
|
||||
});
|
||||
}, [items, deferredSearch]);
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: filteredItems.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => ITEM_HEIGHT,
|
||||
overscan: 5,
|
||||
paddingStart: 8,
|
||||
});
|
||||
|
||||
// Keep a ref to selectedItems so callbacks below stay stable across renders.
|
||||
const selectedItemsRef = useRef(selectedItems);
|
||||
selectedItemsRef.current = selectedItems;
|
||||
|
||||
const toggleItem = useCallback(
|
||||
(item: FilterItem) => {
|
||||
const key = getKey(item);
|
||||
const current = selectedItemsRef.current;
|
||||
if (current.some(s => getKey(s) === key)) {
|
||||
onChange(current.filter(s => getKey(s) !== key));
|
||||
} else {
|
||||
onChange([...current, { id: item.id, name: item.name, values: null }]);
|
||||
}
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const updateItemValues = useCallback(
|
||||
(item: FilterItem, values: string[] | null) => {
|
||||
const key = getKey(item);
|
||||
const current = selectedItemsRef.current;
|
||||
const existing = current.find(s => getKey(s) === key);
|
||||
if (existing) {
|
||||
onChange(current.map(s => (getKey(s) === key ? { ...s, values } : s)));
|
||||
} else {
|
||||
onChange([...current, { id: item.id, name: item.name, values }]);
|
||||
}
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const listHeight = Math.min(virtualizer.getTotalSize(), MAX_LIST_HEIGHT);
|
||||
|
||||
return (
|
||||
<div role="group">
|
||||
<FilterListSearch label={label} onSearch={setSearch} value={search} />
|
||||
{/* Note about unavailable items */}
|
||||
{items.some(item => item.unavailable) && (
|
||||
<div className="text-neutral-11 mt-2 px-4 py-1 text-xs">
|
||||
<span className="line-through">Struck-through</span> items are not found in the selected
|
||||
date range.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Item list */}
|
||||
{filteredItems.length > 0 ? (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="[&>div>div>div>*]:mt-0!"
|
||||
style={{
|
||||
height: listHeight,
|
||||
overflow: 'auto',
|
||||
scrollbarColor: 'var(--color-neutral-7) transparent',
|
||||
scrollbarWidth: 'thin',
|
||||
}}
|
||||
>
|
||||
<div style={{ height: virtualizer.getTotalSize() }}>
|
||||
<div
|
||||
style={{
|
||||
transform: `translateY(${virtualizer.getVirtualItems()[0]?.start ?? 0}px)`,
|
||||
}}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map(virtualItem => {
|
||||
const item = filteredItems[virtualItem.index];
|
||||
const selected = isItemSelected(item, selectedItems);
|
||||
const selection = getItemSelection(item, selectedItems);
|
||||
const hasPartialValues =
|
||||
selected && selection?.values !== null && (selection?.values?.length ?? 0) > 0;
|
||||
|
||||
return (
|
||||
<div key={getKey(item)} style={{ height: virtualItem.size }}>
|
||||
<ItemRow
|
||||
item={item}
|
||||
selected={selected}
|
||||
indeterminate={hasPartialValues}
|
||||
onToggle={toggleItem}
|
||||
selection={selection}
|
||||
onValuesChange={updateItemValues}
|
||||
valuesLabel={valuesLabel}
|
||||
unavailable={item.unavailable}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-neutral-10 pb-2 pt-4 text-center text-[13px] italic">
|
||||
No items found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,226 @@
|
|||
import { useState } from 'react';
|
||||
import { InsightsFilters } from '@/components/base/insights-filters';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Menu as BaseMenu } from '@base-ui/react/menu';
|
||||
import type { Story, StoryDefault } from '@ladle/react';
|
||||
import { FilterDropdown, type FilterDropdownProps } from './filter-dropdown';
|
||||
import type { FilterItem, FilterSelection } from './types';
|
||||
import { ValuesSubPanel } from './values-sub-panel';
|
||||
|
||||
export default {
|
||||
title: 'UI / FilterDropdown',
|
||||
} satisfies StoryDefault;
|
||||
|
||||
function StoryWrapper({
|
||||
items,
|
||||
label,
|
||||
selectedItems: initialValue,
|
||||
valuesLabel,
|
||||
}: Omit<FilterDropdownProps, 'onChange' | 'onRemove'>) {
|
||||
const [value, setValue] = useState<FilterSelection[]>(initialValue);
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="mb-6">
|
||||
<div className="text-neutral-8 mb-2 text-xs font-medium uppercase tracking-wider">
|
||||
Active filters
|
||||
</div>
|
||||
{value.length === 0 ? (
|
||||
<div className="text-neutral-8 text-sm">No filters active</div>
|
||||
) : (
|
||||
<ul className="space-y-1 text-sm">
|
||||
{value.map(selection => (
|
||||
<li key={selection.name} className="text-neutral-11">
|
||||
<span className="text-neutral-12 font-medium">{selection.name}</span>
|
||||
{' — '}
|
||||
{selection.values === null ? (
|
||||
<span className="text-neutral-8 italic">all {valuesLabel}</span>
|
||||
) : (
|
||||
<span className="text-neutral-9">{selection.values.join(', ')}</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<FilterDropdown
|
||||
items={items}
|
||||
label={label}
|
||||
onChange={setValue}
|
||||
onRemove={() => setValue([])}
|
||||
selectedItems={value}
|
||||
valuesLabel={valuesLabel}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const mockClients: FilterItem[] = [
|
||||
{
|
||||
name: 'Hive CLI',
|
||||
values: [
|
||||
'0.12.0',
|
||||
'0.12.1',
|
||||
'0.12.2',
|
||||
'0.12.3',
|
||||
'0.12.4',
|
||||
'0.12.5',
|
||||
'0.12.6',
|
||||
'0.12.7',
|
||||
'0.12.8',
|
||||
'0.12.9',
|
||||
'0.12.10',
|
||||
'0.12.11',
|
||||
'0.12.12',
|
||||
'0.12.13',
|
||||
'0.12.14',
|
||||
'0.12.15',
|
||||
'0.12.16',
|
||||
'0.12.17',
|
||||
],
|
||||
},
|
||||
{ name: 'Hive Client', values: ['1.0.0', '1.0.1', '1.1.0'] },
|
||||
{ name: 'unknown', values: [] },
|
||||
{ name: 'hive-app', values: ['2.0.0', '2.1.0'] },
|
||||
{ name: 'hive-public-api', values: ['1.0.0'] },
|
||||
{ name: 'hive-client-yoga', values: ['3.0.0', '3.1.0', '3.2.0'] },
|
||||
{ name: 'hive-gateway', values: ['0.1.0', '0.2.0', '0.3.0', '1.0.0'] },
|
||||
{ name: 'hive-go-cli', values: ['0.5.0', '0.6.0'] },
|
||||
{ name: 'hive-rust-sdk', values: ['0.1.0', '0.2.0'] },
|
||||
{ name: 'apollo-rover', values: ['0.23.0', '0.24.0', '0.25.0'] },
|
||||
{ name: 'graphql-mesh', values: ['1.0.0', '1.1.0', '1.2.0', '1.3.0'] },
|
||||
{ name: 'federation-gateway', values: ['2.0.0', '2.1.0'] },
|
||||
{ name: 'stellate-edge', values: ['0.9.0', '0.10.0'] },
|
||||
{ name: 'grafbase-cli', values: ['0.4.0', '0.5.0', '0.6.0'] },
|
||||
{ name: 'cosmo-router', values: ['0.1.0', '0.2.0', '0.3.0'] },
|
||||
{ name: 'graphql-yoga', values: ['5.0.0', '5.1.0', '5.2.0', '5.3.0'] },
|
||||
{ name: 'envelop-plugin', values: ['4.0.0', '4.1.0'] },
|
||||
{ name: 'graphql-codegen', values: ['5.0.0', '5.1.0', '5.2.0'] },
|
||||
{ name: 'hive-python-sdk', values: ['0.3.0', '0.4.0'] },
|
||||
{ name: 'hive-dotnet-sdk', values: ['0.1.0'] },
|
||||
{ name: 'schema-registry-action', values: ['1.0.0', '1.1.0', '1.2.0'] },
|
||||
];
|
||||
|
||||
export const Default: Story = () => (
|
||||
<StoryWrapper label="Client" items={mockClients} selectedItems={[]} valuesLabel="versions" />
|
||||
);
|
||||
|
||||
export const WithSelections: Story = () => (
|
||||
<StoryWrapper
|
||||
label="Client"
|
||||
items={mockClients}
|
||||
valuesLabel="versions"
|
||||
selectedItems={[
|
||||
{ name: 'Hive CLI', values: ['0.12.1', '0.12.3'] },
|
||||
{ name: 'hive-gateway', values: null },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
|
||||
export const CustomLabel: Story = () => (
|
||||
<StoryWrapper
|
||||
label="Region"
|
||||
items={[
|
||||
{ name: 'US East', values: ['us-east-1', 'us-east-2'] },
|
||||
{ name: 'US West', values: ['us-west-1', 'us-west-2'] },
|
||||
{ name: 'EU', values: ['eu-west-1', 'eu-central-1', 'eu-north-1'] },
|
||||
{ name: 'Asia Pacific', values: ['ap-southeast-1', 'ap-northeast-1'] },
|
||||
{ name: 'Global', values: [] },
|
||||
]}
|
||||
selectedItems={[]}
|
||||
valuesLabel="zones"
|
||||
/>
|
||||
);
|
||||
|
||||
export const SubPanel: Story = () => {
|
||||
const [selectedValues, setSelectedValues] = useState<string[] | null>(null);
|
||||
const values = ['0.12.0', '0.12.1', '0.12.2', '0.12.3', '0.12.4', '0.12.5'];
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<BaseMenu.Root open modal={false}>
|
||||
<div
|
||||
className={cn(
|
||||
'w-56 rounded-md border p-2 shadow-md',
|
||||
'bg-neutral-1 border-neutral-4 dark:bg-neutral-4 dark:border-neutral-5',
|
||||
)}
|
||||
>
|
||||
<ValuesSubPanel
|
||||
itemName="Hive CLI"
|
||||
values={values}
|
||||
selectedValues={selectedValues}
|
||||
onValuesChange={setSelectedValues}
|
||||
valuesLabel="versions"
|
||||
/>
|
||||
</div>
|
||||
</BaseMenu.Root>
|
||||
<pre className="text-neutral-9 mt-4 text-xs">{JSON.stringify(selectedValues, null, 2)}</pre>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const mockOperations: FilterItem[] = [
|
||||
{ name: 'GetUser', values: [] },
|
||||
{ name: 'ListProducts', values: [] },
|
||||
{ name: 'CreateOrder', values: [] },
|
||||
{ name: 'UpdateCart', values: [] },
|
||||
{ name: 'DeleteItem', values: [] },
|
||||
{ name: 'SearchInventory', values: [] },
|
||||
{ name: 'GetRecommendations', values: [] },
|
||||
{ name: 'ProcessPayment', values: [] },
|
||||
];
|
||||
|
||||
export const InsightsFiltersDropdown: Story = () => {
|
||||
const [clientSelections, setClientSelections] = useState<FilterSelection[]>([]);
|
||||
const [operationSelections, setOperationSelections] = useState<FilterSelection[]>([]);
|
||||
const allSelections = [
|
||||
...operationSelections.map(s => ({ ...s, category: 'Operation' })),
|
||||
...clientSelections.map(s => ({ ...s, category: 'Client' })),
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="mb-6">
|
||||
<div className="text-neutral-8 mb-2 text-xs font-medium uppercase tracking-wider">
|
||||
Active filters
|
||||
</div>
|
||||
{allSelections.length === 0 ? (
|
||||
<div className="text-neutral-8 text-sm">No filters active</div>
|
||||
) : (
|
||||
<ul className="space-y-1 text-sm">
|
||||
{allSelections.map(selection => (
|
||||
<li key={`${selection.category}:${selection.name}`} className="text-neutral-11">
|
||||
<span className="text-neutral-9">{selection.category}:</span>{' '}
|
||||
<span className="text-neutral-12 font-medium">{selection.name}</span>
|
||||
{selection.values !== null && selection.values.length > 0 && (
|
||||
<>
|
||||
{' — '}
|
||||
<span className="text-neutral-9">{selection.values.join(', ')}</span>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<InsightsFilters
|
||||
clientFilterItems={mockClients}
|
||||
clientFilterSelections={clientSelections}
|
||||
operationFilterItems={mockOperations}
|
||||
operationFilterSelections={operationSelections}
|
||||
setClientSelections={setClientSelections}
|
||||
setOperationSelections={setOperationSelections}
|
||||
privateSavedFilterViews={[
|
||||
{
|
||||
id: '1',
|
||||
name: 'My production filter',
|
||||
viewerCanUpdate: true,
|
||||
filters: { operationHashes: [], clientFilters: [], dateRange: null },
|
||||
},
|
||||
]}
|
||||
sharedSavedFilterViews={[]}
|
||||
onApplySavedFilters={() => {}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
import { useState } from 'react';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { Menu, MenuItem } from '@/components/base/menu/menu';
|
||||
import { TriggerButton } from '@/components/base/trigger-button';
|
||||
import { FilterContent } from './filter-content';
|
||||
import type { FilterItem, FilterSelection } from './types';
|
||||
|
||||
export type FilterDropdownProps = {
|
||||
/** Label shown on the trigger button */
|
||||
label: string;
|
||||
/** Available items and their sub-values */
|
||||
items: FilterItem[];
|
||||
/** Currently selected items */
|
||||
selectedItems: FilterSelection[];
|
||||
/** Called when selection changes */
|
||||
onChange: (value: FilterSelection[]) => void;
|
||||
/** Called when the entire filter is removed */
|
||||
onRemove: () => void;
|
||||
/** Label for the sub-values (e.g. "versions", "endpoints"). Used in accessibility labels. */
|
||||
valuesLabel?: string;
|
||||
/** When true, the trigger is visually dimmed and the menu cannot be opened */
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export function FilterDropdown({
|
||||
label,
|
||||
items,
|
||||
selectedItems,
|
||||
onChange,
|
||||
onRemove,
|
||||
valuesLabel = 'values',
|
||||
disabled,
|
||||
}: FilterDropdownProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const selectedCount = selectedItems.length;
|
||||
|
||||
return (
|
||||
<Menu
|
||||
trigger={
|
||||
<TriggerButton
|
||||
accessoryInformation={selectedCount > 0 ? selectedCount.toString() : undefined}
|
||||
disabled={disabled}
|
||||
label={label}
|
||||
rightIcon={{ icon: ChevronDown, withSeparator: true }}
|
||||
/>
|
||||
}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
modal={false}
|
||||
lockScroll
|
||||
side="bottom"
|
||||
align="start"
|
||||
maxWidth="lg"
|
||||
stableWidth
|
||||
sections={[
|
||||
<FilterContent
|
||||
key="content"
|
||||
label={label}
|
||||
items={items}
|
||||
selectedItems={selectedItems}
|
||||
onChange={onChange}
|
||||
valuesLabel={valuesLabel}
|
||||
/>,
|
||||
<MenuItem
|
||||
key="remove"
|
||||
variant="destructiveAction"
|
||||
onClick={() => {
|
||||
onRemove();
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
Remove filter
|
||||
</MenuItem>,
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
type FilterListSearchProps = {
|
||||
label: string;
|
||||
onSearch: (value: string) => void;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export function FilterListSearch({ label, onSearch, value }: FilterListSearchProps) {
|
||||
return (
|
||||
<div className="relative -mx-2">
|
||||
<input
|
||||
type="text"
|
||||
role="searchbox"
|
||||
aria-label={`Search ${label.toLowerCase()}`}
|
||||
placeholder="Search..."
|
||||
value={value}
|
||||
onChange={e => onSearch(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key !== 'Escape') {
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
className="border-neutral-5 text-neutral-11 placeholder:text-neutral-8 w-full rounded-t-md border-b py-2 pl-4 pr-2 outline-none"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
import { memo } from 'react';
|
||||
import { Checkbox } from '@/components/base/checkbox/checkbox';
|
||||
import { Menu, MenuItem } from '@/components/base/menu/menu';
|
||||
import type { FilterItem, FilterSelection } from './types';
|
||||
import { ValuesSubPanel } from './values-sub-panel';
|
||||
|
||||
interface ItemRowProps {
|
||||
item: FilterItem;
|
||||
selected: boolean;
|
||||
indeterminate: boolean;
|
||||
onToggle: (item: FilterItem) => void;
|
||||
selection: FilterSelection | null;
|
||||
onValuesChange: (item: FilterItem, values: string[] | null) => void;
|
||||
valuesLabel: string;
|
||||
unavailable?: boolean;
|
||||
}
|
||||
|
||||
function ItemName({ name, unavailable }: { name: string; unavailable?: boolean }) {
|
||||
return (
|
||||
<span
|
||||
className={`flex-1 truncate ${unavailable ? 'text-neutral-8 line-through' : ''}`}
|
||||
title={name}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export const ItemRow = memo(function ItemRow({
|
||||
item,
|
||||
selected,
|
||||
indeterminate,
|
||||
onToggle,
|
||||
selection,
|
||||
onValuesChange,
|
||||
valuesLabel,
|
||||
unavailable,
|
||||
}: ItemRowProps) {
|
||||
const hasValues = item.values.length > 0;
|
||||
|
||||
if (!hasValues) {
|
||||
return (
|
||||
<MenuItem closeOnClick={false} onClick={() => onToggle(item)}>
|
||||
<Checkbox checked={selected} indeterminate={indeterminate} size="sm" visual />
|
||||
<ItemName name={item.name} unavailable={unavailable} />
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Menu
|
||||
trigger={
|
||||
<MenuItem onClick={() => onToggle(item)}>
|
||||
<Checkbox checked={selected} indeterminate={indeterminate} size="sm" visual />
|
||||
<ItemName name={item.name} unavailable={unavailable} />
|
||||
</MenuItem>
|
||||
}
|
||||
openOnHover
|
||||
delay={100}
|
||||
closeDelay={150}
|
||||
sections={[
|
||||
<ValuesSubPanel
|
||||
key="values"
|
||||
itemName={item.name}
|
||||
values={item.values}
|
||||
selectedValues={selected ? (selection?.values ?? null) : []}
|
||||
onValuesChange={values => onValuesChange(item, values)}
|
||||
valuesLabel={valuesLabel}
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
export interface FilterItem {
|
||||
/** Optional unique identifier. When provided, used for matching instead of name. */
|
||||
id?: string;
|
||||
name: string;
|
||||
values: string[];
|
||||
/** When true, the item is not found in the current date range stats. */
|
||||
unavailable?: boolean;
|
||||
}
|
||||
|
||||
export interface FilterSelection {
|
||||
/** Optional unique identifier. When provided, used for matching instead of name. */
|
||||
id?: string;
|
||||
name: string;
|
||||
values: string[] | null; // null = all values
|
||||
}
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
import { useMemo, useState } from 'react';
|
||||
import { Checkbox } from '@/components/base/checkbox/checkbox';
|
||||
import { FilterListSearch } from '@/components/base/filter-dropdown/filter-list-search';
|
||||
import { MenuItem } from '@/components/base/menu/menu';
|
||||
|
||||
type ValuesSubPanelProps = {
|
||||
itemName: string;
|
||||
values: string[];
|
||||
selectedValues: string[] | null; // null = all values
|
||||
onValuesChange: (values: string[] | null) => void;
|
||||
valuesLabel: string;
|
||||
};
|
||||
|
||||
export function ValuesSubPanel({
|
||||
itemName,
|
||||
values,
|
||||
selectedValues,
|
||||
onValuesChange,
|
||||
valuesLabel,
|
||||
}: ValuesSubPanelProps) {
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const allSelected = selectedValues === null;
|
||||
|
||||
const filteredValues = useMemo(
|
||||
() => values.filter(v => v.toLowerCase().includes(search.toLowerCase())),
|
||||
[values, search],
|
||||
);
|
||||
|
||||
function isValueSelected(val: string) {
|
||||
if (selectedValues === null) return true;
|
||||
return selectedValues.includes(val);
|
||||
}
|
||||
|
||||
function toggleValue(val: string) {
|
||||
if (selectedValues === null) {
|
||||
// Was "all" → deselect this one, select all others
|
||||
onValuesChange(values.filter(v => v !== val));
|
||||
} else if (selectedValues.includes(val)) {
|
||||
const next = selectedValues.filter(v => v !== val);
|
||||
onValuesChange(next.length === 0 ? [] : next);
|
||||
} else {
|
||||
const next = [...selectedValues, val];
|
||||
// If all selected, switch back to null (all)
|
||||
onValuesChange(next.length === values.length ? null : next);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleAllValues() {
|
||||
if (allSelected) {
|
||||
onValuesChange([]);
|
||||
} else {
|
||||
onValuesChange(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<FilterListSearch
|
||||
label={`Search ${valuesLabel} for ${itemName}`}
|
||||
onSearch={setSearch}
|
||||
value={search}
|
||||
/>
|
||||
|
||||
{/* Values list */}
|
||||
<div className="max-h-64 overflow-y-auto [scrollbar-color:var(--color-neutral-7)_transparent] [scrollbar-width:thin]">
|
||||
{/* All values toggle */}
|
||||
<MenuItem closeOnClick={false} onClick={toggleAllValues}>
|
||||
<Checkbox checked={allSelected} size="sm" visual />
|
||||
<span>All {valuesLabel}</span>
|
||||
</MenuItem>
|
||||
|
||||
{/* Individual values */}
|
||||
{filteredValues.map(val => {
|
||||
const checked = isValueSelected(val);
|
||||
return (
|
||||
<MenuItem key={val} closeOnClick={false} onClick={() => toggleValue(val)}>
|
||||
<Checkbox checked={checked} size="sm" visual />
|
||||
<span className="truncate">{val}</span>
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
{filteredValues.length === 0 && (
|
||||
<div className="text-neutral-8 px-2 py-4 text-center text-sm">No {valuesLabel} found</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
176
packages/web/app/src/components/base/insights-filters.tsx
Normal file
176
packages/web/app/src/components/base/insights-filters.tsx
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
import { useState } from 'react';
|
||||
import { ListFilter, X } from 'lucide-react';
|
||||
import { FilterContent } from '@/components/base/filter-dropdown/filter-content';
|
||||
import { FilterItem, FilterSelection } from '@/components/base/filter-dropdown/types';
|
||||
import { Menu, MenuItem } from '@/components/base/menu/menu';
|
||||
import { TriggerButton } from '@/components/base/trigger-button';
|
||||
|
||||
export type SavedFilterView = {
|
||||
id: string;
|
||||
name: string;
|
||||
viewerCanUpdate: boolean;
|
||||
filters: {
|
||||
operationHashes: string[];
|
||||
clientFilters: Array<{ name: string; versions: string[] | null }>;
|
||||
dateRange: { from: string; to: string } | null;
|
||||
};
|
||||
};
|
||||
|
||||
type InsightsFiltersProps = {
|
||||
clientFilterItems: FilterItem[];
|
||||
clientFilterSelections: FilterSelection[];
|
||||
operationFilterItems: FilterItem[];
|
||||
operationFilterSelections: FilterSelection[];
|
||||
privateSavedFilterViews: SavedFilterView[];
|
||||
sharedSavedFilterViews: SavedFilterView[];
|
||||
setClientSelections: (value: FilterSelection[]) => void;
|
||||
setOperationSelections: (value: FilterSelection[]) => void;
|
||||
onApplySavedFilters: (view: SavedFilterView) => void;
|
||||
onManageSavedFilters?: () => void;
|
||||
activeViewId?: string;
|
||||
onClearActiveView?: () => void;
|
||||
};
|
||||
|
||||
function SavedFiltersList({
|
||||
savedFilters,
|
||||
emptyMessage,
|
||||
onApplySavedFilters,
|
||||
}: {
|
||||
savedFilters: SavedFilterView[];
|
||||
emptyMessage: string;
|
||||
onApplySavedFilters: (view: SavedFilterView) => void;
|
||||
}) {
|
||||
if (savedFilters.length === 0) {
|
||||
return <MenuItem disabled>{emptyMessage}</MenuItem>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{savedFilters.map(savedFilter => (
|
||||
<MenuItem key={savedFilter.id} onClick={() => onApplySavedFilters(savedFilter)}>
|
||||
{savedFilter.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function InsightsFilters({
|
||||
clientFilterItems,
|
||||
clientFilterSelections,
|
||||
operationFilterItems,
|
||||
operationFilterSelections,
|
||||
privateSavedFilterViews,
|
||||
sharedSavedFilterViews,
|
||||
setClientSelections,
|
||||
setOperationSelections,
|
||||
onApplySavedFilters,
|
||||
onManageSavedFilters,
|
||||
activeViewId,
|
||||
onClearActiveView,
|
||||
}: InsightsFiltersProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const activeViewName = activeViewId
|
||||
? [...privateSavedFilterViews, ...sharedSavedFilterViews].find(v => v.id === activeViewId)?.name
|
||||
: undefined;
|
||||
|
||||
const handleApplySavedFilter = (view: SavedFilterView) => {
|
||||
setOpen(false);
|
||||
// Defer navigation to next frame so the menu portals unmount first
|
||||
requestAnimationFrame(() => {
|
||||
onApplySavedFilters(view);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu
|
||||
trigger={
|
||||
<TriggerButton
|
||||
label={activeViewName ?? 'Filter'}
|
||||
variant="default"
|
||||
rightIcon={
|
||||
activeViewName && onClearActiveView
|
||||
? {
|
||||
icon: X,
|
||||
action: onClearActiveView,
|
||||
label: 'Clear active view',
|
||||
withSeparator: true,
|
||||
}
|
||||
: { icon: ListFilter, withSeparator: true }
|
||||
}
|
||||
/>
|
||||
}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
modal={false}
|
||||
lockScroll
|
||||
side="bottom"
|
||||
align="start"
|
||||
sections={[
|
||||
[
|
||||
<Menu
|
||||
key="operations"
|
||||
trigger={<MenuItem>Operations</MenuItem>}
|
||||
maxWidth="lg"
|
||||
stableWidth
|
||||
sections={[
|
||||
<FilterContent
|
||||
key="content"
|
||||
label="operations"
|
||||
items={operationFilterItems}
|
||||
selectedItems={operationFilterSelections}
|
||||
onChange={setOperationSelections}
|
||||
/>,
|
||||
]}
|
||||
/>,
|
||||
<Menu
|
||||
key="clients"
|
||||
trigger={<MenuItem>Clients</MenuItem>}
|
||||
maxWidth="lg"
|
||||
stableWidth
|
||||
sections={[
|
||||
<FilterContent
|
||||
key="content"
|
||||
label="clients"
|
||||
items={clientFilterItems}
|
||||
selectedItems={clientFilterSelections}
|
||||
onChange={setClientSelections}
|
||||
valuesLabel="versions"
|
||||
/>,
|
||||
]}
|
||||
/>,
|
||||
],
|
||||
[
|
||||
<Menu
|
||||
key="private"
|
||||
trigger={<MenuItem>My saved filters</MenuItem>}
|
||||
sections={[
|
||||
<SavedFiltersList
|
||||
key="list"
|
||||
savedFilters={privateSavedFilterViews}
|
||||
emptyMessage="No saved private views"
|
||||
onApplySavedFilters={handleApplySavedFilter}
|
||||
/>,
|
||||
]}
|
||||
/>,
|
||||
<Menu
|
||||
key="shared"
|
||||
trigger={<MenuItem>Shared saved filters</MenuItem>}
|
||||
sections={[
|
||||
<SavedFiltersList
|
||||
key="list"
|
||||
savedFilters={sharedSavedFilterViews}
|
||||
emptyMessage="No saved shared views"
|
||||
onApplySavedFilters={handleApplySavedFilter}
|
||||
/>,
|
||||
]}
|
||||
/>,
|
||||
],
|
||||
<MenuItem key="manage" variant="navigationLink" onClick={onManageSavedFilters}>
|
||||
Manage saved filters
|
||||
</MenuItem>,
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
120
packages/web/app/src/components/base/menu/menu.stories.tsx
Normal file
120
packages/web/app/src/components/base/menu/menu.stories.tsx
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import { useState } from 'react';
|
||||
import { FileText, LogOut, Plus, Settings, Users } from 'lucide-react';
|
||||
import { Checkbox } from '@/components/base/checkbox/checkbox';
|
||||
import { Menu, MenuItem } from '@/components/base/menu/menu';
|
||||
import type { Story, StoryDefault } from '@ladle/react';
|
||||
import { Flex } from '../story-utils';
|
||||
|
||||
export default {
|
||||
title: 'UI / Menu',
|
||||
} satisfies StoryDefault;
|
||||
|
||||
export const Default: Story = () => (
|
||||
<Flex>
|
||||
<Menu
|
||||
trigger={
|
||||
<button className="border-neutral-5 text-neutral-11 rounded-md border px-3 py-1.5 text-sm">
|
||||
Open Menu
|
||||
</button>
|
||||
}
|
||||
sections={[
|
||||
[
|
||||
<MenuItem key="settings" onClick={() => console.log('settings')}>
|
||||
<Settings className="mr-2 size-4" />
|
||||
Settings
|
||||
</MenuItem>,
|
||||
<MenuItem key="docs" onClick={() => console.log('docs')}>
|
||||
<FileText className="mr-2 size-4" />
|
||||
Documentation
|
||||
</MenuItem>,
|
||||
],
|
||||
<MenuItem key="logout" onClick={() => console.log('logout')}>
|
||||
<LogOut className="mr-2 size-4" />
|
||||
Log out
|
||||
</MenuItem>,
|
||||
]}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
export const WithSubmenu: Story = () => (
|
||||
<Flex>
|
||||
<Menu
|
||||
trigger={
|
||||
<button className="border-neutral-5 text-neutral-11 rounded-md border px-3 py-1.5 text-sm">
|
||||
User Menu
|
||||
</button>
|
||||
}
|
||||
align="start"
|
||||
sections={[
|
||||
[
|
||||
<Menu
|
||||
key="org"
|
||||
trigger={
|
||||
<MenuItem>
|
||||
<Users className="mr-2 size-4" />
|
||||
Switch organization
|
||||
</MenuItem>
|
||||
}
|
||||
sections={[
|
||||
[
|
||||
<MenuItem key="acme" active>
|
||||
acme-corp
|
||||
</MenuItem>,
|
||||
<MenuItem key="personal">personal</MenuItem>,
|
||||
<MenuItem key="test">test-org</MenuItem>,
|
||||
],
|
||||
<MenuItem key="create">
|
||||
<Plus className="mr-2 size-4" />
|
||||
Create organization
|
||||
</MenuItem>,
|
||||
]}
|
||||
/>,
|
||||
<MenuItem key="settings">
|
||||
<Settings className="mr-2 size-4" />
|
||||
Profile settings
|
||||
</MenuItem>,
|
||||
],
|
||||
<MenuItem key="logout">
|
||||
<LogOut className="mr-2 size-4" />
|
||||
Log out
|
||||
</MenuItem>,
|
||||
]}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
export const WithCheckboxItems: Story = () => {
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set(['typescript']));
|
||||
|
||||
function toggle(item: string) {
|
||||
setSelected(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(item)) next.delete(item);
|
||||
else next.add(item);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
const items = ['typescript', 'javascript', 'python', 'rust'];
|
||||
|
||||
return (
|
||||
<Flex>
|
||||
<Menu
|
||||
trigger={
|
||||
<button className="border-neutral-5 text-neutral-11 rounded-md border px-3 py-1.5 text-sm">
|
||||
Languages ({selected.size})
|
||||
</button>
|
||||
}
|
||||
sections={[
|
||||
items.map(item => (
|
||||
<MenuItem key={item} closeOnClick={false} onClick={() => toggle(item)}>
|
||||
<Checkbox checked={selected.has(item)} size="sm" visual />
|
||||
<span className="ml-2">{item}</span>
|
||||
</MenuItem>
|
||||
)),
|
||||
]}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
320
packages/web/app/src/components/base/menu/menu.tsx
Normal file
320
packages/web/app/src/components/base/menu/menu.tsx
Normal file
|
|
@ -0,0 +1,320 @@
|
|||
import {
|
||||
createContext,
|
||||
Fragment,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { ArrowRight, ChevronRight } from 'lucide-react';
|
||||
import { Menu as BaseMenu } from '@base-ui/react/menu';
|
||||
|
||||
// --- Contexts ---
|
||||
|
||||
const MenuDepthContext = createContext(0);
|
||||
|
||||
type SubmenuTriggerContextValue = {
|
||||
openOnHover?: boolean;
|
||||
delay?: number;
|
||||
closeDelay?: number;
|
||||
} | null;
|
||||
|
||||
const SubmenuTriggerContext = createContext<SubmenuTriggerContextValue>(null);
|
||||
|
||||
// --- Styles ---
|
||||
|
||||
const menuVariants = cva(
|
||||
'px-2 pb-2 z-50 min-w-[12rem] text-[13px] rounded-md border shadow-md shadow-neutral-1/30 outline-none bg-neutral-2 border-neutral-5 dark:bg-neutral-4 dark:border-neutral-5',
|
||||
{
|
||||
variants: {
|
||||
maxWidth: {
|
||||
default: 'max-w-75', // 300px
|
||||
none: 'max-w-none',
|
||||
sm: 'max-w-60', // 240px
|
||||
lg: 'max-w-[380px]',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
maxWidth: 'default',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const menuItemVariants = cva(
|
||||
'flex h-7 cursor-pointer select-none items-center rounded-sm outline-none gap-2.5 first:mt-2',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'pl-2 text-neutral-10',
|
||||
navigationLink: 'hover:text-accent text-accent_80 justify-end pr-2 hover:bg-transparent',
|
||||
action: 'pl-2 hover:bg-accent_10 hover:text-accent text-accent_80',
|
||||
destructiveAction: 'pl-2 text-red-400 hover:bg-red-300/10',
|
||||
},
|
||||
highlighted: {
|
||||
true: '',
|
||||
false: '',
|
||||
},
|
||||
active: {
|
||||
true: '',
|
||||
false: '',
|
||||
},
|
||||
disabled: {
|
||||
true: 'pointer-events-none opacity-50',
|
||||
false: '',
|
||||
},
|
||||
},
|
||||
compoundVariants: [
|
||||
{ highlighted: true, className: 'bg-neutral-5 text-neutral-12' },
|
||||
{ active: true, className: 'bg-neutral-5 text-neutral-12' },
|
||||
],
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
highlighted: false,
|
||||
active: false,
|
||||
disabled: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function menuItemClassName(
|
||||
state: { highlighted: boolean; disabled: boolean },
|
||||
{
|
||||
active,
|
||||
variant,
|
||||
}: {
|
||||
active?: boolean;
|
||||
variant?: VariantProps<typeof menuItemVariants>['variant'];
|
||||
},
|
||||
) {
|
||||
return menuItemVariants({
|
||||
variant,
|
||||
highlighted: state.highlighted,
|
||||
disabled: state.disabled,
|
||||
active: active ?? false,
|
||||
});
|
||||
}
|
||||
|
||||
function renderSections(sections: Array<ReactNode | ReactNode[]>): ReactNode {
|
||||
const result: ReactNode[] = [];
|
||||
let keyCounter = 0;
|
||||
|
||||
for (let i = 0; i < sections.length; i++) {
|
||||
const section = sections[i];
|
||||
const items = Array.isArray(section) ? section : [section];
|
||||
const filtered = items.filter(Boolean);
|
||||
if (filtered.length === 0) continue;
|
||||
|
||||
if (result.length > 0) {
|
||||
result.push(
|
||||
<div key={`sep-${keyCounter++}`} role="separator" className="bg-neutral-5 my-2 h-px" />,
|
||||
);
|
||||
}
|
||||
|
||||
for (const item of filtered) {
|
||||
result.push(<Fragment key={`item-${keyCounter++}`}>{item}</Fragment>);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// --- Hooks ---
|
||||
|
||||
/**
|
||||
* Returns a callback ref that prevents a popup from shrinking while open.
|
||||
* Uses a ResizeObserver to track the widest size seen and sets it as min-width,
|
||||
* so the popup can grow as wider content scrolls into view but never jumps narrower.
|
||||
* Resets when the element unmounts (i.e. popup closes).
|
||||
*/
|
||||
function useStableWidth(enabled: boolean) {
|
||||
const observerRef = useRef<ResizeObserver | null>(null);
|
||||
const maxWidthSeen = useRef(0);
|
||||
|
||||
return useCallback(
|
||||
(node: HTMLElement | null) => {
|
||||
if (observerRef.current) {
|
||||
observerRef.current.disconnect();
|
||||
observerRef.current = null;
|
||||
}
|
||||
maxWidthSeen.current = 0;
|
||||
|
||||
if (!node || !enabled) return;
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
const w = node.offsetWidth;
|
||||
if (w > maxWidthSeen.current) {
|
||||
maxWidthSeen.current = w;
|
||||
node.style.minWidth = `${w}px`;
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(node);
|
||||
observerRef.current = observer;
|
||||
},
|
||||
[enabled],
|
||||
);
|
||||
}
|
||||
|
||||
// --- Menu ---
|
||||
|
||||
type MenuProps = {
|
||||
trigger: React.ReactElement;
|
||||
sections: Array<ReactNode | ReactNode[]>;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
modal?: boolean;
|
||||
side?: 'top' | 'bottom' | 'left' | 'right';
|
||||
align?: 'start' | 'center' | 'end';
|
||||
sideOffset?: number;
|
||||
/** Controls the max-width of the popup. Defaults to 300px. */
|
||||
maxWidth?: 'default' | 'none' | 'sm' | 'lg';
|
||||
/** Open the submenu when the trigger is hovered (only relevant for nested menus) */
|
||||
openOnHover?: boolean;
|
||||
/** Delay in ms before the submenu opens on hover */
|
||||
delay?: number;
|
||||
/** Delay in ms before the submenu closes when the pointer leaves */
|
||||
closeDelay?: number;
|
||||
/**
|
||||
* Prevent page scroll while the menu is open. Only applies to root menus.
|
||||
* Compensates for scrollbar width to avoid layout shift.
|
||||
*/
|
||||
lockScroll?: boolean;
|
||||
/**
|
||||
* Prevent the popup from shrinking while open. The width ratchets upward
|
||||
* as wider content scrolls into view (e.g. virtualized lists) but never
|
||||
* jumps narrower. Resets each time the popup reopens.
|
||||
*/
|
||||
stableWidth?: boolean;
|
||||
};
|
||||
|
||||
function Menu({
|
||||
trigger,
|
||||
sections,
|
||||
open,
|
||||
onOpenChange,
|
||||
modal,
|
||||
side,
|
||||
align,
|
||||
sideOffset,
|
||||
maxWidth,
|
||||
openOnHover,
|
||||
delay,
|
||||
closeDelay,
|
||||
lockScroll,
|
||||
stableWidth,
|
||||
}: MenuProps) {
|
||||
const parentDepth = useContext(MenuDepthContext);
|
||||
const isNested = parentDepth > 0;
|
||||
const contentDepth = parentDepth + 1;
|
||||
|
||||
// Lock page scroll when a root menu is open to prevent scroll-through
|
||||
// (wheel events on the popup propagating to the page behind it).
|
||||
useEffect(() => {
|
||||
if (!lockScroll || isNested || !open) return;
|
||||
|
||||
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
|
||||
document.documentElement.style.overflow = 'hidden';
|
||||
document.documentElement.style.paddingRight = `${scrollbarWidth}px`;
|
||||
|
||||
return () => {
|
||||
document.documentElement.style.overflow = '';
|
||||
document.documentElement.style.paddingRight = '';
|
||||
};
|
||||
}, [lockScroll, isNested, open]);
|
||||
|
||||
const popupRef = useStableWidth(stableWidth ?? false);
|
||||
|
||||
const resolvedSide = side ?? (isNested ? 'right' : 'bottom');
|
||||
const resolvedAlign = align ?? (isNested ? 'start' : undefined);
|
||||
const resolvedSideOffset = sideOffset ?? (isNested ? 6 : 8);
|
||||
|
||||
const popupContent = (
|
||||
<MenuDepthContext.Provider value={contentDepth}>
|
||||
{renderSections(sections)}
|
||||
</MenuDepthContext.Provider>
|
||||
);
|
||||
|
||||
if (isNested) {
|
||||
return (
|
||||
<BaseMenu.SubmenuRoot>
|
||||
<SubmenuTriggerContext.Provider value={{ openOnHover, delay, closeDelay }}>
|
||||
{trigger}
|
||||
</SubmenuTriggerContext.Provider>
|
||||
<BaseMenu.Portal>
|
||||
<BaseMenu.Positioner
|
||||
side={resolvedSide}
|
||||
align={resolvedAlign}
|
||||
sideOffset={resolvedSideOffset}
|
||||
className="outline-none"
|
||||
>
|
||||
<BaseMenu.Popup ref={popupRef} className={menuVariants({ maxWidth })}>
|
||||
{popupContent}
|
||||
</BaseMenu.Popup>
|
||||
</BaseMenu.Positioner>
|
||||
</BaseMenu.Portal>
|
||||
</BaseMenu.SubmenuRoot>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<BaseMenu.Root open={open} onOpenChange={onOpenChange} modal={modal}>
|
||||
<BaseMenu.Trigger render={trigger} />
|
||||
<BaseMenu.Portal>
|
||||
<BaseMenu.Positioner
|
||||
side={resolvedSide}
|
||||
align={resolvedAlign}
|
||||
sideOffset={resolvedSideOffset}
|
||||
className="outline-none"
|
||||
>
|
||||
<BaseMenu.Popup ref={popupRef} className={menuVariants({ maxWidth })}>
|
||||
{popupContent}
|
||||
</BaseMenu.Popup>
|
||||
</BaseMenu.Positioner>
|
||||
</BaseMenu.Portal>
|
||||
</BaseMenu.Root>
|
||||
);
|
||||
}
|
||||
|
||||
// --- MenuItem ---
|
||||
|
||||
type MenuItemProps = Omit<BaseMenu.Item.Props, 'className'> & {
|
||||
active?: boolean;
|
||||
variant?: VariantProps<typeof menuItemVariants>['variant'];
|
||||
};
|
||||
|
||||
function MenuItem({ active, variant, children, ...props }: MenuItemProps) {
|
||||
const submenuTriggerCtx = useContext(SubmenuTriggerContext);
|
||||
|
||||
if (submenuTriggerCtx) {
|
||||
return (
|
||||
<BaseMenu.SubmenuTrigger
|
||||
className={(state: BaseMenu.SubmenuTrigger.State) =>
|
||||
menuItemClassName(state, { active, variant })
|
||||
}
|
||||
openOnHover={submenuTriggerCtx.openOnHover}
|
||||
delay={submenuTriggerCtx.delay}
|
||||
closeDelay={submenuTriggerCtx.closeDelay}
|
||||
{...(props as Omit<BaseMenu.SubmenuTrigger.Props, 'className'>)}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto size-3.5" />
|
||||
</BaseMenu.SubmenuTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<BaseMenu.Item
|
||||
className={(state: BaseMenu.Item.State) => menuItemClassName(state, { active, variant })}
|
||||
{...(props as Omit<BaseMenu.Item.Props, 'className'>)}
|
||||
>
|
||||
{children}
|
||||
{variant === 'navigationLink' && <ArrowRight className="ml-1 size-3.5" />}
|
||||
</BaseMenu.Item>
|
||||
);
|
||||
}
|
||||
|
||||
export { Menu, MenuItem };
|
||||
146
packages/web/app/src/components/base/popover/popover.stories.tsx
Normal file
146
packages/web/app/src/components/base/popover/popover.stories.tsx
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
import { useState } from 'react';
|
||||
import { Flex } from '@/components/base/story-utils';
|
||||
import type { Story, StoryDefault } from '@ladle/react';
|
||||
import { Popover } from './popover';
|
||||
|
||||
export default {
|
||||
title: 'Base / Popover',
|
||||
} satisfies StoryDefault;
|
||||
|
||||
export const Default: Story = () => (
|
||||
<div className="flex items-center justify-center p-16">
|
||||
<Popover
|
||||
trigger={
|
||||
<button className="border-neutral-5 text-neutral-11 rounded-md border px-3 py-1.5 text-sm">
|
||||
Open popover
|
||||
</button>
|
||||
}
|
||||
content={<p className="text-neutral-11 text-sm">This is a popover panel.</p>}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const WithArrow: Story = () => (
|
||||
<Flex>
|
||||
{(['top', 'right', 'bottom', 'left'] as const).map(side => (
|
||||
<Popover
|
||||
key={side}
|
||||
trigger={
|
||||
<button className="border-neutral-5 text-neutral-11 rounded-md border px-3 py-1.5 text-sm">
|
||||
{side}
|
||||
</button>
|
||||
}
|
||||
content={<p className="text-neutral-11 text-sm">Arrow on {side}.</p>}
|
||||
side={side}
|
||||
arrow
|
||||
/>
|
||||
))}
|
||||
</Flex>
|
||||
);
|
||||
|
||||
export const Controlled: Story = () => {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<div className="flex items-center gap-4 p-16">
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
trigger={
|
||||
<button className="border-neutral-5 text-neutral-11 rounded-md border px-3 py-1.5 text-sm">
|
||||
{open ? 'Close' : 'Open'}
|
||||
</button>
|
||||
}
|
||||
content={
|
||||
<div>
|
||||
<p className="text-neutral-11 text-sm">Controlled popover.</p>
|
||||
<button
|
||||
onClick={() => setOpen(false)}
|
||||
className="border-neutral-5 text-neutral-11 mt-2 rounded-sm border px-2 py-1 text-xs"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<span className="text-neutral-8 text-xs">State: {open ? 'open' : 'closed'}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Placement: Story = () => (
|
||||
<div className="flex items-center justify-center gap-4 p-32">
|
||||
{(['top', 'right', 'bottom', 'left'] as const).map(side => (
|
||||
<Popover
|
||||
key={side}
|
||||
trigger={
|
||||
<button className="border-neutral-5 text-neutral-11 rounded-md border px-3 py-1.5 text-sm">
|
||||
{side}
|
||||
</button>
|
||||
}
|
||||
content={<p className="text-neutral-11 text-sm">Placed on {side}.</p>}
|
||||
side={side}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const WithTitle: Story = () => {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<div className="flex items-center justify-center p-16">
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
trigger={
|
||||
<button className="border-neutral-5 text-neutral-11 rounded-md border px-3 py-1.5 text-sm">
|
||||
Open form popover
|
||||
</button>
|
||||
}
|
||||
title="Save to filter collections"
|
||||
content={
|
||||
<div className="space-y-3">
|
||||
<input
|
||||
className="border-neutral-5 bg-neutral-3 text-neutral-12 w-full rounded-md border px-3 py-1.5 text-sm"
|
||||
placeholder="Name this filter collection"
|
||||
/>
|
||||
<button className="bg-accent text-neutral-1 w-full rounded-md px-3 py-1.5 text-sm">
|
||||
Save filter
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const WithTitleAndDescription: Story = () => {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<div className="flex items-center justify-center p-16">
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
trigger={
|
||||
<button className="border-neutral-5 text-neutral-11 rounded-md border px-3 py-1.5 text-sm">
|
||||
Open confirmation popover
|
||||
</button>
|
||||
}
|
||||
title="Update saved filter"
|
||||
description="This will overwrite the current configuration with your current filter selections."
|
||||
content={
|
||||
<div className="flex gap-2">
|
||||
<button className="bg-accent text-neutral-1 flex-1 rounded-md px-3 py-1.5 text-sm">
|
||||
Update filter
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setOpen(false)}
|
||||
className="border-neutral-5 text-neutral-11 flex-1 rounded-md border px-3 py-1.5 text-sm"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
165
packages/web/app/src/components/base/popover/popover.tsx
Normal file
165
packages/web/app/src/components/base/popover/popover.tsx
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Popover as BasePopover } from '@base-ui/react/popover';
|
||||
|
||||
const popoverPopupVariants = cva(
|
||||
'z-50 rounded-md border shadow-md shadow-neutral-1/30 outline-none',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-neutral-2 border-neutral-5 dark:bg-neutral-4 dark:border-neutral-5',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const widthMap = {
|
||||
sm: 'w-64',
|
||||
md: 'w-80',
|
||||
lg: 'w-96',
|
||||
} as const;
|
||||
|
||||
type PopoverCommonProps = {
|
||||
/** Element that triggers the popover on click */
|
||||
trigger: React.ReactElement;
|
||||
/** Which side of the trigger to position on */
|
||||
side?: 'top' | 'bottom' | 'left' | 'right';
|
||||
/** Alignment along the side */
|
||||
align?: 'start' | 'center' | 'end';
|
||||
/** Gap between trigger and popup in px */
|
||||
sideOffset?: number;
|
||||
/** Visual variant */
|
||||
variant?: VariantProps<typeof popoverPopupVariants>['variant'];
|
||||
/** Show an arrow pointing to the trigger */
|
||||
arrow?: boolean;
|
||||
/** Controlled open state */
|
||||
open?: boolean;
|
||||
/** Callback when open state changes */
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
};
|
||||
|
||||
/** Raw mode: full control over content */
|
||||
type PopoverRawProps = PopoverCommonProps & {
|
||||
/** Content rendered directly inside the popup */
|
||||
content: React.ReactNode;
|
||||
title?: never;
|
||||
};
|
||||
|
||||
/** Structured mode: auto-renders header with title and close button */
|
||||
type PopoverStructuredProps = PopoverCommonProps & {
|
||||
/** Title text shown in the header */
|
||||
title: string;
|
||||
/** Body content rendered below the header */
|
||||
content: React.ReactNode;
|
||||
/** Description text rendered between header and content */
|
||||
description?: React.ReactNode;
|
||||
/** Width of the popover panel */
|
||||
width?: 'sm' | 'md' | 'lg';
|
||||
/** Hide the close button in the header */
|
||||
hideCloseButton?: boolean;
|
||||
};
|
||||
|
||||
export type PopoverProps = PopoverRawProps | PopoverStructuredProps;
|
||||
|
||||
function isStructured(props: PopoverProps): props is PopoverStructuredProps {
|
||||
return 'title' in props && props.title !== undefined;
|
||||
}
|
||||
|
||||
export function Popover(props: PopoverProps) {
|
||||
const {
|
||||
trigger,
|
||||
side = 'bottom',
|
||||
align,
|
||||
sideOffset = 8,
|
||||
variant,
|
||||
arrow,
|
||||
open,
|
||||
onOpenChange,
|
||||
} = props;
|
||||
|
||||
let inner: React.ReactNode;
|
||||
|
||||
if (isStructured(props)) {
|
||||
const widthClass = widthMap[props.width ?? 'md'];
|
||||
inner = (
|
||||
<div className={cn(widthClass, 'p-4')}>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="text-sm font-medium">{props.title}</span>
|
||||
{!props.hideCloseButton && (
|
||||
<button
|
||||
onClick={() => onOpenChange?.(false)}
|
||||
className="text-neutral-10 hover:text-neutral-12 rounded-sm p-0.5"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{props.description && <p className="text-neutral-11 mb-3 text-sm">{props.description}</p>}
|
||||
{props.content}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
inner = props.content;
|
||||
}
|
||||
|
||||
return (
|
||||
<BasePopover.Root open={open} onOpenChange={onOpenChange}>
|
||||
<BasePopover.Trigger render={trigger} />
|
||||
<BasePopover.Portal>
|
||||
<BasePopover.Positioner
|
||||
side={side}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className="outline-none"
|
||||
>
|
||||
<BasePopover.Popup className={popoverPopupVariants({ variant })}>
|
||||
{arrow && <PopoverArrow />}
|
||||
{inner}
|
||||
</BasePopover.Popup>
|
||||
</BasePopover.Positioner>
|
||||
</BasePopover.Portal>
|
||||
</BasePopover.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function PopoverArrow() {
|
||||
return (
|
||||
<BasePopover.Arrow
|
||||
className={cn(
|
||||
'group',
|
||||
'data-[side=bottom]:bottom-[calc(100%-2px)]',
|
||||
'data-[side=top]:top-[calc(100%-2px)]',
|
||||
'data-[side=left]:left-[calc(100%-8px)]',
|
||||
'data-[side=right]:right-[calc(100%-8px)]',
|
||||
)}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="8"
|
||||
fill="none"
|
||||
viewBox="0 0 20 8"
|
||||
className={cn(
|
||||
'block',
|
||||
'group-data-[side=top]:rotate-180',
|
||||
'group-data-[side=left]:rotate-90',
|
||||
'group-data-[side=right]:-rotate-90',
|
||||
)}
|
||||
>
|
||||
<path
|
||||
className="fill-neutral-2 dark:fill-neutral-4"
|
||||
d="M9.664.602 4.808 4.973A4 4 0 0 1 2.132 6H0v2h20V6h-1.465a4 4 0 0 1-2.676-1.027L11.002.603a1 1 0 0 0-1.338 0"
|
||||
/>
|
||||
<path
|
||||
className="fill-neutral-5"
|
||||
d="M10.333 1.345 5.477 5.716A5 5 0 0 1 2.132 7H0V6h2.132a4 4 0 0 0 2.676-1.027L9.664.603a1 1 0 0 1 1.338 0l4.857 4.37A4 4 0 0 0 18.535 6H20v1h-1.465a5 5 0 0 1-3.345-1.284z"
|
||||
/>
|
||||
</svg>
|
||||
</BasePopover.Arrow>
|
||||
);
|
||||
}
|
||||
9
packages/web/app/src/components/base/story-utils.tsx
Normal file
9
packages/web/app/src/components/base/story-utils.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
// Helpful layout utils for stories
|
||||
|
||||
type ChildrenProp = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export function Flex({ children }: ChildrenProp) {
|
||||
return <div className="flex items-center justify-center gap-4 p-32">{children}</div>;
|
||||
}
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
import { useState } from 'react';
|
||||
import { ChevronDown, ListFilter, RefreshCw, X } from 'lucide-react';
|
||||
import { Menu, MenuItem } from '@/components/base/menu/menu';
|
||||
import type { Story, StoryDefault } from '@ladle/react';
|
||||
import { Flex } from './story-utils';
|
||||
import { TriggerButton } from './trigger-button';
|
||||
|
||||
export default {
|
||||
title: 'Base / TriggerButton',
|
||||
} satisfies StoryDefault;
|
||||
|
||||
export const Default: Story = () => (
|
||||
<Flex>
|
||||
<TriggerButton label="Last 7 days" rightIcon={{ icon: ChevronDown, withSeparator: true }} />
|
||||
<TriggerButton label="Filter" rightIcon={{ icon: ListFilter, withSeparator: true }} />
|
||||
<TriggerButton label="Filter" rightIcon={{ icon: ListFilter, withSeparator: false }} />
|
||||
</Flex>
|
||||
);
|
||||
|
||||
export const Active: Story = () => (
|
||||
<Flex>
|
||||
<TriggerButton
|
||||
label="Client"
|
||||
accessoryInformation="2"
|
||||
variant="active"
|
||||
rightIcon={{ icon: ChevronDown, withSeparator: true }}
|
||||
/>
|
||||
<TriggerButton
|
||||
label="Operation"
|
||||
accessoryInformation="O9SwSomeOperationName"
|
||||
variant="default"
|
||||
rightIcon={{
|
||||
icon: X,
|
||||
action: () => alert('Cleared!'),
|
||||
label: 'Clear filter',
|
||||
withSeparator: true,
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
export const Action: Story = () => (
|
||||
<Flex>
|
||||
<TriggerButton label="Save this filter view" variant="action" />
|
||||
<TriggerButton
|
||||
label="Save this filter view"
|
||||
variant="action"
|
||||
rightIcon={{ icon: ChevronDown, withSeparator: false }}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
export const IconOnly: Story = () => (
|
||||
<Flex>
|
||||
<TriggerButton layout="iconOnly" icon={RefreshCw} aria-label="Refresh" />
|
||||
<TriggerButton layout="iconOnly" icon={RefreshCw} aria-label="Refresh" variant="active" />
|
||||
<TriggerButton layout="iconOnly" icon={RefreshCw} aria-label="Refresh" variant="action" />
|
||||
</Flex>
|
||||
);
|
||||
|
||||
export const WithMenu: Story = () => {
|
||||
const [count, setCount] = useState(0);
|
||||
return (
|
||||
<Flex>
|
||||
<Menu
|
||||
trigger={
|
||||
<TriggerButton
|
||||
accessoryInformation={count > 0 ? count.toString() : undefined}
|
||||
label="Client"
|
||||
variant={count > 0 ? 'active' : 'default'}
|
||||
rightIcon={{ icon: ChevronDown, withSeparator: true }}
|
||||
/>
|
||||
}
|
||||
align="start"
|
||||
sections={[
|
||||
[
|
||||
<MenuItem key="add" onClick={() => setCount(c => c + 1)}>
|
||||
Add selection
|
||||
</MenuItem>,
|
||||
<MenuItem key="clear" onClick={() => setCount(0)}>
|
||||
Clear
|
||||
</MenuItem>,
|
||||
],
|
||||
]}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
134
packages/web/app/src/components/base/trigger-button.tsx
Normal file
134
packages/web/app/src/components/base/trigger-button.tsx
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import { forwardRef } from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { type LucideIcon } from 'lucide-react';
|
||||
|
||||
const triggerButtonVariants = cva(
|
||||
'group inline-flex items-center rounded-sm border text-xs font-medium transition-colors',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: [
|
||||
'bg-neutral-2 border-neutral-5 hover:bg-neutral-1 hover:border-neutral-5 text-neutral-9 hover:text-neutral-11',
|
||||
'dark:text-neutral-11 dark:bg-neutral-3 dark:border-neutral-4 dark:hover:bg-neutral-4 dark:hover:border-neutral-5 ',
|
||||
],
|
||||
active: [
|
||||
'border-neutral-5 dark:border-neutral-6 text-neutral-12',
|
||||
'bg-neutral-3 dark:bg-neutral-5',
|
||||
],
|
||||
action: [
|
||||
'border-dashed border-accent_30 text-accent_80 bg-accent_08',
|
||||
'hover:border-accent_80 hover:text-accent hover:bg-accent_10',
|
||||
],
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const separatorClass = 'border-l [border-left-color:inherit]';
|
||||
|
||||
type CommonProps = Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'className' | 'style'> &
|
||||
VariantProps<typeof triggerButtonVariants> & {
|
||||
/** When true, the button is visually dimmed and non-interactive */
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
type LabelLayout = CommonProps & {
|
||||
layout?: 'label';
|
||||
/** Button label text (always shown) */
|
||||
label: string;
|
||||
/** Icon rendered to the right of label */
|
||||
rightIcon?: {
|
||||
action?: () => void;
|
||||
icon: LucideIcon;
|
||||
label?: string;
|
||||
withSeparator: boolean;
|
||||
};
|
||||
/** Accessory information to be displayed after the button label and before any actions */
|
||||
accessoryInformation?: string;
|
||||
};
|
||||
|
||||
type IconOnlyLayout = CommonProps & {
|
||||
layout: 'iconOnly';
|
||||
icon: LucideIcon;
|
||||
'aria-label': string;
|
||||
};
|
||||
|
||||
type TriggerButtonProps = LabelLayout | IconOnlyLayout;
|
||||
|
||||
export const TriggerButton = forwardRef<HTMLButtonElement, TriggerButtonProps>(
|
||||
function TriggerButton(props, ref) {
|
||||
const { variant, disabled, ...rest } = props;
|
||||
|
||||
// Remove custom props so they don't get spread onto the DOM element
|
||||
const domProps = rest as Record<string, unknown>;
|
||||
delete domProps.layout;
|
||||
delete domProps.label;
|
||||
delete domProps.rightIcon;
|
||||
delete domProps.accessoryInformation;
|
||||
delete domProps.icon;
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
className={triggerButtonVariants({ variant })}
|
||||
disabled={disabled}
|
||||
style={disabled ? { opacity: 0.5, pointerEvents: 'none' } : undefined}
|
||||
{...domProps}
|
||||
>
|
||||
{props.layout === 'iconOnly' ? (
|
||||
<span className="flex items-center p-1.5">
|
||||
<props.icon className="size-4" />
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="px-3 py-1.5 text-[13px]">{props.label}</span>
|
||||
|
||||
{props.accessoryInformation != null && (
|
||||
<span className={`${separatorClass} px-3 py-1.5`}>{props.accessoryInformation}</span>
|
||||
)}
|
||||
{props.rightIcon && (
|
||||
<span
|
||||
role={props.rightIcon.action ? 'button' : undefined}
|
||||
tabIndex={props.rightIcon.action ? 0 : undefined}
|
||||
aria-label={props.rightIcon.label ?? undefined}
|
||||
onPointerDown={
|
||||
props.rightIcon.action
|
||||
? e => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onClick={
|
||||
props.rightIcon.action
|
||||
? e => {
|
||||
e.stopPropagation();
|
||||
props.rightIcon!.action!();
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onKeyDown={
|
||||
props.rightIcon.action
|
||||
? e => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
props.rightIcon!.action!();
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
className={`${props.rightIcon.withSeparator && separatorClass} text-neutral-8 ${props.rightIcon.action ? 'hover:text-neutral-11' : 'group-hover:text-neutral-12'} flex items-center px-2 py-1.5 transition-colors`}
|
||||
>
|
||||
<props.rightIcon.icon className="size-4" />
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
@ -246,6 +246,7 @@ export const TargetLayout = ({
|
|||
projectSlug: props.projectSlug,
|
||||
targetSlug: props.targetSlug,
|
||||
}}
|
||||
search={{}}
|
||||
>
|
||||
Insights
|
||||
</Link>
|
||||
|
|
|
|||
|
|
@ -1,779 +0,0 @@
|
|||
import { ChangeEvent, ComponentType, ReactElement, useCallback, useEffect, useState } from 'react';
|
||||
import { FilterIcon } from 'lucide-react';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
import { FixedSizeList, ListChildComponentProps } from 'react-window';
|
||||
import { useQuery } from 'urql';
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { Checkbox, Input } from '@/components/v2';
|
||||
import { FragmentType, graphql, useFragment } from '@/gql';
|
||||
import { DateRangeInput } from '@/gql/graphql';
|
||||
import { useFormattedNumber, useToggle } from '@/lib/hooks';
|
||||
|
||||
const OperationsFilter_OperationStatsValuesConnectionFragment = graphql(`
|
||||
fragment OperationsFilter_OperationStatsValuesConnectionFragment on OperationStatsValuesConnection {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
operationHash
|
||||
name
|
||||
...OperationRow_OperationStatsValuesFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
function OperationsFilter({
|
||||
onClose,
|
||||
isOpen,
|
||||
onFilter,
|
||||
operationStatsConnection,
|
||||
selected,
|
||||
clientOperationStatsConnection,
|
||||
}: {
|
||||
onClose(): void;
|
||||
onFilter(keys: string[]): void;
|
||||
isOpen: boolean;
|
||||
operationStatsConnection: FragmentType<
|
||||
typeof OperationsFilter_OperationStatsValuesConnectionFragment
|
||||
>;
|
||||
clientOperationStatsConnection?:
|
||||
| FragmentType<typeof OperationsFilter_OperationStatsValuesConnectionFragment>
|
||||
| undefined;
|
||||
selected?: string[];
|
||||
}): ReactElement {
|
||||
const operations = useFragment(
|
||||
OperationsFilter_OperationStatsValuesConnectionFragment,
|
||||
operationStatsConnection,
|
||||
);
|
||||
|
||||
const clientFilteredOperations = useFragment(
|
||||
OperationsFilter_OperationStatsValuesConnectionFragment,
|
||||
clientOperationStatsConnection,
|
||||
);
|
||||
|
||||
function getOperationHashes() {
|
||||
const items: string[] = [];
|
||||
for (const { node: op } of operations.edges) {
|
||||
if (op.operationHash) {
|
||||
items.push(op.operationHash);
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
const [selectedItems, setSelectedItems] = useState<string[]>(() =>
|
||||
getOperationHashes().filter(hash => selected?.includes(hash) ?? true),
|
||||
);
|
||||
|
||||
const onSelect = useCallback(
|
||||
(operationHash: string, selected: boolean) => {
|
||||
const itemAt = selectedItems.findIndex(hash => hash === operationHash);
|
||||
const exists = itemAt > -1;
|
||||
|
||||
if (selected && !exists) {
|
||||
setSelectedItems([...selectedItems, operationHash]);
|
||||
} else if (!selected && exists) {
|
||||
setSelectedItems(selectedItems.filter(hash => hash !== operationHash));
|
||||
}
|
||||
},
|
||||
[selectedItems, setSelectedItems],
|
||||
);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const debouncedFilter = useDebouncedCallback((value: string) => {
|
||||
setVisibleOperations(
|
||||
operations.edges.filter(({ node: op }) =>
|
||||
op.name.toLocaleLowerCase().includes(value.toLocaleLowerCase()),
|
||||
),
|
||||
);
|
||||
}, 500);
|
||||
|
||||
const onChange = useCallback(
|
||||
(event: ChangeEvent<HTMLInputElement>) => {
|
||||
const { value } = event.currentTarget;
|
||||
|
||||
setSearchTerm(value);
|
||||
debouncedFilter(value);
|
||||
},
|
||||
[setSearchTerm, debouncedFilter],
|
||||
);
|
||||
|
||||
const [visibleOperations, setVisibleOperations] = useState(operations.edges);
|
||||
|
||||
const selectAll = useCallback(() => {
|
||||
setSelectedItems(getOperationHashes());
|
||||
}, [operations]);
|
||||
const selectNone = useCallback(() => {
|
||||
setSelectedItems([]);
|
||||
}, [setSelectedItems]);
|
||||
|
||||
const renderRow = useCallback<ComponentType<ListChildComponentProps>>(
|
||||
({ index, style }) => {
|
||||
const operation = visibleOperations[index].node;
|
||||
const clientOpStats = clientFilteredOperations?.edges.find(
|
||||
e => e.node.operationHash === operation.operationHash,
|
||||
)?.node;
|
||||
|
||||
return (
|
||||
<OperationRow
|
||||
style={style}
|
||||
key={operation.id}
|
||||
operationStats={operation}
|
||||
clientOperationStats={clientFilteredOperations == null ? false : clientOpStats}
|
||||
selected={selectedItems.includes(operation.operationHash || '')}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[visibleOperations, selectedItems, onSelect, clientFilteredOperations],
|
||||
);
|
||||
|
||||
return (
|
||||
<Sheet open={isOpen} onOpenChange={onClose}>
|
||||
<SheetContent className="w-[500px] sm:max-w-none">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Filter by operation</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex h-full flex-col space-y-3 py-4">
|
||||
<Input
|
||||
size="medium"
|
||||
placeholder="Search for operation..."
|
||||
onChange={onChange}
|
||||
value={searchTerm}
|
||||
onClear={() => {
|
||||
setSearchTerm('');
|
||||
setVisibleOperations(operations.edges);
|
||||
}}
|
||||
/>
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<Button variant="link" onClick={selectAll}>
|
||||
All
|
||||
</Button>
|
||||
<Button variant="link" onClick={selectNone}>
|
||||
None
|
||||
</Button>
|
||||
<Button className="ml-auto" onClick={selectAll}>
|
||||
Reset
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={selectedItems.length === 0}
|
||||
onClick={() => {
|
||||
onFilter(selectedItems);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grow pl-1">
|
||||
{clientFilteredOperations && (
|
||||
<div className="text-neutral-8 text-right text-xs">
|
||||
<span className="text-neutral-10">selected</span> / all clients
|
||||
</div>
|
||||
)}
|
||||
<AutoSizer>
|
||||
{({ height, width }) =>
|
||||
!height || !width ? (
|
||||
<></>
|
||||
) : (
|
||||
<FixedSizeList
|
||||
height={height}
|
||||
width={width}
|
||||
itemCount={visibleOperations.length}
|
||||
itemSize={24}
|
||||
overscanCount={5}
|
||||
>
|
||||
{renderRow}
|
||||
</FixedSizeList>
|
||||
)
|
||||
}
|
||||
</AutoSizer>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
const OperationsFilterContainer_OperationStatsQuery = graphql(`
|
||||
query OperationsFilterContainer_OperationStatsQuery(
|
||||
$targetSelector: TargetSelectorInput!
|
||||
$period: DateRangeInput!
|
||||
$filter: OperationStatsFilterInput
|
||||
$hasFilter: Boolean!
|
||||
) {
|
||||
target(reference: { bySelector: $targetSelector }) {
|
||||
id
|
||||
operationsStats(period: $period) {
|
||||
operations {
|
||||
edges {
|
||||
__typename
|
||||
}
|
||||
...OperationsFilter_OperationStatsValuesConnectionFragment
|
||||
}
|
||||
}
|
||||
clientOperationStats: operationsStats(period: $period, filter: $filter)
|
||||
@include(if: $hasFilter) {
|
||||
operations {
|
||||
edges {
|
||||
__typename
|
||||
}
|
||||
...OperationsFilter_OperationStatsValuesConnectionFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
function OperationsFilterContainer({
|
||||
period,
|
||||
isOpen,
|
||||
onClose,
|
||||
onFilter,
|
||||
selected,
|
||||
organizationSlug,
|
||||
projectSlug,
|
||||
targetSlug,
|
||||
clientNames,
|
||||
}: {
|
||||
onFilter(keys: string[]): void;
|
||||
onClose(): void;
|
||||
isOpen: boolean;
|
||||
period: DateRangeInput;
|
||||
selected?: string[];
|
||||
organizationSlug: string;
|
||||
projectSlug: string;
|
||||
targetSlug: string;
|
||||
clientNames?: string[];
|
||||
}): ReactElement | null {
|
||||
const [query, refresh] = useQuery({
|
||||
query: OperationsFilterContainer_OperationStatsQuery,
|
||||
variables: {
|
||||
targetSelector: {
|
||||
organizationSlug,
|
||||
projectSlug,
|
||||
targetSlug,
|
||||
},
|
||||
period,
|
||||
filter: clientNames ? { clientNames } : undefined,
|
||||
hasFilter: !!clientNames?.length,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!query.fetching) {
|
||||
refresh({ requestPolicy: 'network-only' });
|
||||
}
|
||||
}, [period]);
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (query.fetching || query.error || !query.data?.target) {
|
||||
return <Spinner />;
|
||||
}
|
||||
|
||||
const { target } = query.data;
|
||||
|
||||
return (
|
||||
<OperationsFilter
|
||||
operationStatsConnection={target.operationsStats.operations}
|
||||
clientOperationStatsConnection={target.clientOperationStats?.operations}
|
||||
selected={selected}
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
onFilter={hashes => {
|
||||
onFilter(hashes.length === target.operationsStats.operations.edges.length ? [] : hashes);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const OperationRow_OperationStatsValuesFragment = graphql(`
|
||||
fragment OperationRow_OperationStatsValuesFragment on OperationStatsValues {
|
||||
id
|
||||
name
|
||||
operationHash
|
||||
count
|
||||
}
|
||||
`);
|
||||
|
||||
function OperationRow({
|
||||
operationStats,
|
||||
clientOperationStats,
|
||||
selected,
|
||||
onSelect,
|
||||
style,
|
||||
}: {
|
||||
operationStats: FragmentType<typeof OperationRow_OperationStatsValuesFragment>;
|
||||
/** Stats for the operation filtered by the selected clients */
|
||||
clientOperationStats?:
|
||||
| FragmentType<typeof OperationRow_OperationStatsValuesFragment>
|
||||
| null
|
||||
| false;
|
||||
selected: boolean;
|
||||
onSelect(id: string, selected: boolean): void;
|
||||
style: any;
|
||||
}): ReactElement {
|
||||
const operation = useFragment(OperationRow_OperationStatsValuesFragment, operationStats);
|
||||
const requests = useFormattedNumber(operation.count);
|
||||
const clientsOperation = useFragment(
|
||||
OperationRow_OperationStatsValuesFragment,
|
||||
clientOperationStats || null,
|
||||
);
|
||||
const hasClientOperation = clientOperationStats !== false;
|
||||
const clientsRequests = useFormattedNumber(clientsOperation?.count);
|
||||
const hash = operation.operationHash || '';
|
||||
const change = useCallback(() => {
|
||||
if (hash) {
|
||||
onSelect(hash, !selected);
|
||||
}
|
||||
}, [onSelect, hash, selected]);
|
||||
|
||||
const Totals = () => {
|
||||
if (hasClientOperation) {
|
||||
return (
|
||||
<div className="text-neutral-10 flex shrink-0 text-right">
|
||||
<span>{clientsRequests === '-' ? 0 : clientsRequests}</span>
|
||||
<span className="text-neutral-8 ml-1 truncate">/ {requests}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <div className="text-neutral-8 shrink-0 text-right">{requests}</div>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={style} className="flex items-center gap-4 truncate">
|
||||
<Checkbox checked={selected} onCheckedChange={change} id={hash} />
|
||||
<label
|
||||
htmlFor={hash}
|
||||
className="flex w-full cursor-pointer items-center justify-between gap-4 overflow-hidden"
|
||||
>
|
||||
<span className="grow overflow-hidden text-ellipsis">{operation.name}</span>
|
||||
<Totals />
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function OperationsFilterTrigger({
|
||||
period,
|
||||
onFilter,
|
||||
selected,
|
||||
organizationSlug,
|
||||
projectSlug,
|
||||
targetSlug,
|
||||
clientNames,
|
||||
}: {
|
||||
period: DateRangeInput;
|
||||
onFilter(keys: string[]): void;
|
||||
selected?: string[];
|
||||
organizationSlug: string;
|
||||
projectSlug: string;
|
||||
targetSlug: string;
|
||||
clientNames?: string[];
|
||||
}): ReactElement {
|
||||
const [isOpen, toggle] = useToggle();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant="outline" className="bg-neutral-2" onClick={toggle}>
|
||||
<span>Operations ({selected?.length || 'all'})</span>
|
||||
<FilterIcon className="ml-2 size-4" />
|
||||
</Button>
|
||||
<OperationsFilterContainer
|
||||
organizationSlug={organizationSlug}
|
||||
projectSlug={projectSlug}
|
||||
targetSlug={targetSlug}
|
||||
isOpen={isOpen}
|
||||
onClose={toggle}
|
||||
period={period}
|
||||
selected={selected}
|
||||
onFilter={onFilter}
|
||||
clientNames={clientNames}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const ClientRow_ClientStatsValuesFragment = graphql(`
|
||||
fragment ClientRow_ClientStatsValuesFragment on ClientStatsValues {
|
||||
name
|
||||
count
|
||||
}
|
||||
`);
|
||||
|
||||
function ClientRow({
|
||||
selected,
|
||||
onSelect,
|
||||
style,
|
||||
...props
|
||||
}: {
|
||||
client: FragmentType<typeof ClientRow_ClientStatsValuesFragment>;
|
||||
clientOperationStats:
|
||||
| FragmentType<typeof ClientRow_ClientStatsValuesFragment>
|
||||
| false
|
||||
| undefined;
|
||||
selected: boolean;
|
||||
onSelect(id: string, selected: boolean): void;
|
||||
style: any;
|
||||
}): ReactElement {
|
||||
const client = useFragment(ClientRow_ClientStatsValuesFragment, props.client);
|
||||
const clientOperation = useFragment(
|
||||
ClientRow_ClientStatsValuesFragment,
|
||||
props.clientOperationStats || null,
|
||||
);
|
||||
const requests = useFormattedNumber(client.count);
|
||||
const hash = client.name;
|
||||
const change = useCallback(() => {
|
||||
if (hash) {
|
||||
onSelect(hash, !selected);
|
||||
}
|
||||
}, [onSelect, hash, selected]);
|
||||
|
||||
const Totals = () => {
|
||||
if (props.clientOperationStats !== false) {
|
||||
return (
|
||||
<div className="text-neutral-10 flex shrink-0 text-right">
|
||||
<span>{clientOperation?.count ?? 0}</span>
|
||||
<span className="text-neutral-8 ml-1 truncate">/ {requests}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <div className="text-neutral-8 shrink-0 text-right">{requests}</div>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={style} className="flex items-center gap-4 truncate">
|
||||
<Checkbox checked={selected} onCheckedChange={change} id={hash} />
|
||||
<label
|
||||
htmlFor={hash}
|
||||
className="flex w-full cursor-pointer items-center justify-between gap-4 overflow-hidden"
|
||||
>
|
||||
<span className="grow overflow-hidden text-ellipsis">{client.name}</span>
|
||||
<Totals />
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ClientsFilter_ClientStatsValuesConnectionFragment = graphql(`
|
||||
fragment ClientsFilter_ClientStatsValuesConnectionFragment on ClientStatsValuesConnection {
|
||||
edges {
|
||||
node {
|
||||
name
|
||||
...ClientRow_ClientStatsValuesFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
function ClientsFilter({
|
||||
onClose,
|
||||
isOpen,
|
||||
onFilter,
|
||||
clientStatsConnection,
|
||||
operationStatsConnection,
|
||||
selected,
|
||||
}: {
|
||||
onClose(): void;
|
||||
onFilter(keys: string[]): void;
|
||||
isOpen: boolean;
|
||||
clientStatsConnection: FragmentType<typeof ClientsFilter_ClientStatsValuesConnectionFragment>;
|
||||
operationStatsConnection?:
|
||||
| FragmentType<typeof ClientsFilter_ClientStatsValuesConnectionFragment>
|
||||
| undefined;
|
||||
selected?: string[];
|
||||
}): ReactElement {
|
||||
const clientConnection = useFragment(
|
||||
ClientsFilter_ClientStatsValuesConnectionFragment,
|
||||
clientStatsConnection,
|
||||
);
|
||||
function getClientNames() {
|
||||
return clientConnection.edges.map(edge => edge.node.name);
|
||||
}
|
||||
|
||||
const [selectedItems, setSelectedItems] = useState<string[]>(() =>
|
||||
getClientNames().filter(name => selected?.includes(name) ?? true),
|
||||
);
|
||||
|
||||
const onSelect = useCallback(
|
||||
(operationHash: string, selected: boolean) => {
|
||||
const itemAt = selectedItems.findIndex(hash => hash === operationHash);
|
||||
const exists = itemAt > -1;
|
||||
|
||||
if (selected && !exists) {
|
||||
setSelectedItems([...selectedItems, operationHash]);
|
||||
} else if (!selected && exists) {
|
||||
setSelectedItems(selectedItems.filter(hash => hash !== operationHash));
|
||||
}
|
||||
},
|
||||
[selectedItems, setSelectedItems],
|
||||
);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const debouncedFilter = useDebouncedCallback((value: string) => {
|
||||
setVisibleOperations(
|
||||
clientConnection.edges.filter(edge =>
|
||||
edge.node.name.toLocaleLowerCase().includes(value.toLocaleLowerCase()),
|
||||
),
|
||||
);
|
||||
}, 500);
|
||||
|
||||
const onChange = useCallback(
|
||||
(event: ChangeEvent<HTMLInputElement>) => {
|
||||
const { value } = event.currentTarget;
|
||||
|
||||
setSearchTerm(value);
|
||||
debouncedFilter(value);
|
||||
},
|
||||
[setSearchTerm, debouncedFilter],
|
||||
);
|
||||
|
||||
const [visibleOperations, setVisibleOperations] = useState(clientConnection.edges);
|
||||
|
||||
const selectAll = useCallback(() => {
|
||||
setSelectedItems(getClientNames());
|
||||
}, [clientConnection.edges]);
|
||||
const selectNone = useCallback(() => {
|
||||
setSelectedItems([]);
|
||||
}, [setSelectedItems]);
|
||||
|
||||
const operationConnection = useFragment(
|
||||
ClientsFilter_ClientStatsValuesConnectionFragment,
|
||||
operationStatsConnection ?? null,
|
||||
);
|
||||
|
||||
const renderRow = useCallback<ComponentType<ListChildComponentProps>>(
|
||||
({ index, style }) => {
|
||||
const client = visibleOperations[index].node;
|
||||
const operationStats =
|
||||
operationConnection == null
|
||||
? false
|
||||
: operationConnection.edges.find(e => e.node.name === client.name)?.node;
|
||||
|
||||
return (
|
||||
<ClientRow
|
||||
style={style}
|
||||
key={client.name}
|
||||
client={client}
|
||||
clientOperationStats={operationStats}
|
||||
selected={selectedItems.includes(client.name || '')}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[visibleOperations, selectedItems, onSelect, operationConnection],
|
||||
);
|
||||
|
||||
return (
|
||||
<Sheet open={isOpen} onOpenChange={onClose}>
|
||||
<SheetContent className="w-[500px] sm:max-w-none">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Filter by client</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex h-full flex-col space-y-3 py-4">
|
||||
<Input
|
||||
size="medium"
|
||||
placeholder="Search for operation..."
|
||||
onChange={onChange}
|
||||
value={searchTerm}
|
||||
onClear={() => {
|
||||
setSearchTerm('');
|
||||
setVisibleOperations(clientConnection.edges);
|
||||
}}
|
||||
/>
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<Button variant="link" onClick={selectAll}>
|
||||
All
|
||||
</Button>
|
||||
<Button variant="link" onClick={selectNone}>
|
||||
None
|
||||
</Button>
|
||||
<Button className="ml-auto" onClick={selectAll}>
|
||||
Reset
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={selectedItems.length === 0}
|
||||
onClick={() => {
|
||||
onFilter(selectedItems);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grow pl-1">
|
||||
{operationStatsConnection && (
|
||||
<div className="text-neutral-8 text-right text-xs">
|
||||
<span className="text-neutral-10">selected</span> / all operations
|
||||
</div>
|
||||
)}
|
||||
<AutoSizer>
|
||||
{({ height, width }) =>
|
||||
!height || !width ? (
|
||||
<></>
|
||||
) : (
|
||||
<FixedSizeList
|
||||
height={height}
|
||||
width={width}
|
||||
itemCount={visibleOperations.length}
|
||||
itemSize={24}
|
||||
overscanCount={5}
|
||||
>
|
||||
{renderRow}
|
||||
</FixedSizeList>
|
||||
)
|
||||
}
|
||||
</AutoSizer>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
const ClientsFilterContainer_ClientStatsQuery = graphql(`
|
||||
query ClientsFilterContainer_ClientStats(
|
||||
$targetSelector: TargetSelectorInput!
|
||||
$period: DateRangeInput!
|
||||
$filter: OperationStatsFilterInput
|
||||
$hasFilter: Boolean!
|
||||
) {
|
||||
target(reference: { bySelector: $targetSelector }) {
|
||||
id
|
||||
operationsStats(period: $period) {
|
||||
clients {
|
||||
...ClientsFilter_ClientStatsValuesConnectionFragment
|
||||
edges {
|
||||
node {
|
||||
__typename
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
filteredOperationStats: operationsStats(period: $period, filter: $filter)
|
||||
@include(if: $hasFilter) {
|
||||
clients {
|
||||
...ClientsFilter_ClientStatsValuesConnectionFragment
|
||||
edges {
|
||||
node {
|
||||
__typename
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
function ClientsFilterContainer({
|
||||
period,
|
||||
isOpen,
|
||||
onClose,
|
||||
onFilter,
|
||||
selected,
|
||||
selectedOperationIds,
|
||||
organizationSlug,
|
||||
projectSlug,
|
||||
targetSlug,
|
||||
}: {
|
||||
onFilter(keys: string[]): void;
|
||||
onClose(): void;
|
||||
isOpen: boolean;
|
||||
period: DateRangeInput;
|
||||
selected?: string[];
|
||||
organizationSlug: string;
|
||||
projectSlug: string;
|
||||
targetSlug: string;
|
||||
selectedOperationIds?: string[];
|
||||
}): ReactElement | null {
|
||||
const [query, refresh] = useQuery({
|
||||
query: ClientsFilterContainer_ClientStatsQuery,
|
||||
variables: {
|
||||
targetSelector: {
|
||||
organizationSlug,
|
||||
projectSlug,
|
||||
targetSlug,
|
||||
},
|
||||
period,
|
||||
filter: selectedOperationIds ? { operationIds: selectedOperationIds } : undefined,
|
||||
hasFilter: !!selectedOperationIds?.length,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!query.fetching) {
|
||||
refresh({ requestPolicy: 'network-only' });
|
||||
}
|
||||
}, [period]);
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (query.fetching || query.error || !query.data?.target) {
|
||||
return <Spinner />;
|
||||
}
|
||||
|
||||
const allClients = query.data.target?.operationsStats?.clients.edges ?? [];
|
||||
|
||||
return (
|
||||
<ClientsFilter
|
||||
clientStatsConnection={query.data.target.operationsStats.clients}
|
||||
operationStatsConnection={query.data.target.filteredOperationStats?.clients}
|
||||
selected={selected}
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
onFilter={clientNames => {
|
||||
onFilter(clientNames.length === allClients.length ? [] : clientNames);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ClientsFilterTrigger({
|
||||
period,
|
||||
onFilter,
|
||||
selected,
|
||||
organizationSlug,
|
||||
projectSlug,
|
||||
targetSlug,
|
||||
selectedOperationIds,
|
||||
}: {
|
||||
period: DateRangeInput;
|
||||
onFilter(keys: string[]): void;
|
||||
selected?: string[];
|
||||
selectedOperationIds?: string[];
|
||||
organizationSlug: string;
|
||||
projectSlug: string;
|
||||
targetSlug: string;
|
||||
}): ReactElement {
|
||||
const [isOpen, toggle] = useToggle();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant="outline" className="bg-neutral-2" onClick={toggle}>
|
||||
<span>Clients ({selected?.length || 'all'})</span>
|
||||
<FilterIcon className="ml-2 size-4" />
|
||||
</Button>
|
||||
<ClientsFilterContainer
|
||||
organizationSlug={organizationSlug}
|
||||
projectSlug={projectSlug}
|
||||
targetSlug={targetSlug}
|
||||
isOpen={isOpen}
|
||||
onClose={toggle}
|
||||
period={period}
|
||||
selected={selected}
|
||||
selectedOperationIds={selectedOperationIds}
|
||||
onFilter={onFilter}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -11,9 +11,8 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp
|
|||
import { Sortable, Table, TBody, Td, Th, THead, Tr } from '@/components/v2';
|
||||
import { env } from '@/env/frontend';
|
||||
import { FragmentType, graphql, useFragment } from '@/gql';
|
||||
import { DateRangeInput } from '@/gql/graphql';
|
||||
import { DateRangeInput, OperationStatsFilterInput } from '@/gql/graphql';
|
||||
import { useDecimal, useFormattedDuration, useFormattedNumber } from '@/lib/hooks';
|
||||
import { pick } from '@/lib/object';
|
||||
import { ChevronUpIcon, ExclamationTriangleIcon } from '@radix-ui/react-icons';
|
||||
import {
|
||||
createColumnHelper,
|
||||
|
|
@ -79,11 +78,10 @@ function OperationRow({
|
|||
operationName: operation.name,
|
||||
operationHash: operation.hash,
|
||||
}}
|
||||
search={searchParams => ({
|
||||
...pick(searchParams, ['clients']),
|
||||
search={{
|
||||
from: selectedPeriod?.from ? encodeURIComponent(selectedPeriod.from) : undefined,
|
||||
to: selectedPeriod?.to ? encodeURIComponent(selectedPeriod.to) : undefined,
|
||||
})}
|
||||
}}
|
||||
>
|
||||
{operation.name}
|
||||
</Link>
|
||||
|
|
@ -501,8 +499,7 @@ export function OperationsList({
|
|||
projectSlug,
|
||||
targetSlug,
|
||||
period,
|
||||
operationsFilter = [],
|
||||
clientNamesFilter = [],
|
||||
filter,
|
||||
selectedPeriod,
|
||||
}: {
|
||||
className?: string;
|
||||
|
|
@ -510,9 +507,7 @@ export function OperationsList({
|
|||
projectSlug: string;
|
||||
targetSlug: string;
|
||||
period: DateRangeInput;
|
||||
/** Operation IDs to filter on */
|
||||
operationsFilter: string[];
|
||||
clientNamesFilter: string[];
|
||||
filter: OperationStatsFilterInput;
|
||||
selectedPeriod: null | { to: string; from: string };
|
||||
}): ReactElement {
|
||||
const [clientFilter, setClientFilter] = useState<string | null>(null);
|
||||
|
|
@ -525,10 +520,7 @@ export function OperationsList({
|
|||
targetSlug,
|
||||
},
|
||||
period,
|
||||
filter: {
|
||||
operationIds: operationsFilter,
|
||||
clientNames: clientNamesFilter,
|
||||
},
|
||||
filter,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -538,7 +530,7 @@ export function OperationsList({
|
|||
if (!query.fetching) {
|
||||
refetch();
|
||||
}
|
||||
}, [period]);
|
||||
}, [period, filter]);
|
||||
|
||||
return (
|
||||
<OperationsFallback
|
||||
|
|
|
|||
|
|
@ -16,8 +16,8 @@ import { useQuery } from 'urql';
|
|||
import { Section } from '@/components/common';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { CHART_PRIMARY_COLOR } from '@/constants';
|
||||
import { FragmentType, graphql, useFragment } from '@/gql';
|
||||
import { OperationStatsFilterInput } from '@/gql/graphql';
|
||||
import { createAdaptiveTimeFormatter } from '@/lib/date-time';
|
||||
import {
|
||||
formatDuration,
|
||||
|
|
@ -270,7 +270,7 @@ function OverTimeStats({
|
|||
const { failuresOverTime = [], requestsOverTime = [] } =
|
||||
useFragment(OverTimeStats_OperationsStatsFragment, operationStats) ?? {};
|
||||
|
||||
const styles = useChartStyles();
|
||||
const { styles, colors } = useChartStyles();
|
||||
|
||||
const requests = useMemo(() => {
|
||||
if (requestsOverTime?.length) {
|
||||
|
|
@ -327,7 +327,7 @@ function OverTimeStats({
|
|||
min: 0,
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
color: '#595959',
|
||||
color: colors.grid,
|
||||
type: 'dashed',
|
||||
},
|
||||
},
|
||||
|
|
@ -340,7 +340,7 @@ function OverTimeStats({
|
|||
min: 0,
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
color: '#595959',
|
||||
color: colors.grid,
|
||||
type: 'dashed',
|
||||
},
|
||||
},
|
||||
|
|
@ -355,7 +355,7 @@ function OverTimeStats({
|
|||
name: 'Requests',
|
||||
showSymbol: false,
|
||||
smooth: false,
|
||||
color: CHART_PRIMARY_COLOR,
|
||||
color: colors.primary,
|
||||
areaStyle: {},
|
||||
emphasis: {
|
||||
focus: 'series',
|
||||
|
|
@ -368,7 +368,7 @@ function OverTimeStats({
|
|||
name: 'Failures',
|
||||
showSymbol: false,
|
||||
smooth: false,
|
||||
color: '#ef4444',
|
||||
color: colors.error,
|
||||
areaStyle: {},
|
||||
emphasis: {
|
||||
focus: 'series',
|
||||
|
|
@ -434,7 +434,7 @@ function ClientsStats(props: {
|
|||
targetSlug: string;
|
||||
}): ReactElement {
|
||||
const router = useRouter();
|
||||
const styles = useChartStyles();
|
||||
const { styles, colors } = useChartStyles();
|
||||
const operationStats = useFragment(ClientsStats_OperationsStatsFragment, props.operationStats);
|
||||
const sortedClients = useMemo(() => {
|
||||
return operationStats?.clients.edges?.length
|
||||
|
|
@ -626,7 +626,7 @@ function ClientsStats(props: {
|
|||
type: 'value',
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
color: '#595959',
|
||||
color: colors.grid,
|
||||
type: 'dashed',
|
||||
},
|
||||
},
|
||||
|
|
@ -643,7 +643,7 @@ function ClientsStats(props: {
|
|||
{
|
||||
type: 'bar',
|
||||
data: byClient.values,
|
||||
color: CHART_PRIMARY_COLOR,
|
||||
color: colors.primary,
|
||||
},
|
||||
],
|
||||
}}
|
||||
|
|
@ -667,7 +667,7 @@ function ClientsStats(props: {
|
|||
type: 'value',
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
color: '#595959',
|
||||
color: colors.grid,
|
||||
type: 'dashed',
|
||||
},
|
||||
},
|
||||
|
|
@ -683,7 +683,7 @@ function ClientsStats(props: {
|
|||
{
|
||||
type: 'bar',
|
||||
data: byVersion.values,
|
||||
color: CHART_PRIMARY_COLOR,
|
||||
color: colors.primary,
|
||||
},
|
||||
],
|
||||
}}
|
||||
|
|
@ -744,7 +744,8 @@ function ClientsStats(props: {
|
|||
a: {
|
||||
padding: 4,
|
||||
height: 15,
|
||||
backgroundColor: 'rgba(0,0,0,0.7)',
|
||||
color: colors.overlayText,
|
||||
backgroundColor: colors.overlayBg,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -752,18 +753,18 @@ function ClientsStats(props: {
|
|||
show: true,
|
||||
height: 30,
|
||||
formatter: '{b}',
|
||||
color: '#fff',
|
||||
color: styles.textStyle.color,
|
||||
backgroundColor: 'transparent',
|
||||
padding: 5,
|
||||
fontWeight: 'bold',
|
||||
overflow: 'none',
|
||||
},
|
||||
itemStyle: {
|
||||
borderColor: 'rgba(255, 255, 255, 0.2)',
|
||||
borderColor: colors.overlayBorder,
|
||||
},
|
||||
levels: getLevelOption(),
|
||||
data: byClientAndVersion,
|
||||
color: CHART_PRIMARY_COLOR,
|
||||
color: colors.primary,
|
||||
},
|
||||
],
|
||||
}}
|
||||
|
|
@ -806,7 +807,7 @@ function LatencyOverTimeStats({
|
|||
}: {
|
||||
operationStats?: FragmentType<typeof LatencyOverTimeStats_OperationStatsFragment> | null;
|
||||
}): ReactElement {
|
||||
const styles = useChartStyles();
|
||||
const { styles, colors } = useChartStyles();
|
||||
const { durationOverTime: duration = [] } =
|
||||
useFragment(LatencyOverTimeStats_OperationStatsFragment, operationStats) ?? {};
|
||||
const p75 = useMemo(() => {
|
||||
|
|
@ -852,10 +853,10 @@ function LatencyOverTimeStats({
|
|||
}
|
||||
|
||||
const series = [
|
||||
createSeries('p75', '#10b981', p75),
|
||||
createSeries('p90', '#0ea5e9', p90),
|
||||
createSeries('p95', '#8b5cf6', p95),
|
||||
createSeries('p99', '#ec4899', p99),
|
||||
createSeries('p75', colors.p75, p75),
|
||||
createSeries('p90', colors.p90, p90),
|
||||
createSeries('p95', colors.p95, p95),
|
||||
createSeries('p99', colors.p99, p99),
|
||||
];
|
||||
|
||||
return (
|
||||
|
|
@ -887,7 +888,7 @@ function LatencyOverTimeStats({
|
|||
boundaryGap: false,
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
color: '#595959',
|
||||
color: colors.grid,
|
||||
type: 'dashed',
|
||||
},
|
||||
},
|
||||
|
|
@ -903,7 +904,7 @@ function LatencyOverTimeStats({
|
|||
min: 0,
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
color: '#595959',
|
||||
color: colors.grid,
|
||||
type: 'dashed',
|
||||
},
|
||||
},
|
||||
|
|
@ -942,7 +943,7 @@ function RpmOverTimeStats({
|
|||
resolution: number;
|
||||
operationStats: FragmentType<typeof RpmOverTimeStats_OperationStatsFragment> | null;
|
||||
}): ReactElement {
|
||||
const styles = useChartStyles();
|
||||
const { styles, colors } = useChartStyles();
|
||||
const { requestsOverTime: requests = [] } =
|
||||
useFragment(RpmOverTimeStats_OperationStatsFragment, operationStats) ?? {};
|
||||
|
||||
|
|
@ -985,7 +986,7 @@ function RpmOverTimeStats({
|
|||
boundaryGap: false,
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
color: '#595959',
|
||||
color: colors.grid,
|
||||
type: 'dashed',
|
||||
},
|
||||
},
|
||||
|
|
@ -1008,7 +1009,7 @@ function RpmOverTimeStats({
|
|||
},
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
color: '#595959',
|
||||
color: colors.grid,
|
||||
type: 'dashed',
|
||||
},
|
||||
},
|
||||
|
|
@ -1021,12 +1022,12 @@ function RpmOverTimeStats({
|
|||
symbol: 'none',
|
||||
smooth: false,
|
||||
areaStyle: {
|
||||
color: CHART_PRIMARY_COLOR,
|
||||
color: colors.primary,
|
||||
},
|
||||
lineStyle: {
|
||||
color: CHART_PRIMARY_COLOR,
|
||||
color: colors.primary,
|
||||
},
|
||||
color: CHART_PRIMARY_COLOR,
|
||||
color: colors.primary,
|
||||
large: true,
|
||||
data: rpmOverTime,
|
||||
},
|
||||
|
|
@ -1044,8 +1045,7 @@ export function OperationsStats({
|
|||
projectSlug,
|
||||
targetSlug,
|
||||
period,
|
||||
operationsFilter,
|
||||
clientNamesFilter,
|
||||
filter,
|
||||
resolution,
|
||||
mode,
|
||||
dateRangeText,
|
||||
|
|
@ -1059,8 +1059,7 @@ export function OperationsStats({
|
|||
};
|
||||
dateRangeText: string;
|
||||
resolution: number;
|
||||
operationsFilter: string[];
|
||||
clientNamesFilter: Array<string>;
|
||||
filter: OperationStatsFilterInput;
|
||||
mode: 'operation-page' | 'operation-list';
|
||||
}): ReactElement {
|
||||
const [query, refetchQuery] = useQuery({
|
||||
|
|
@ -1072,10 +1071,7 @@ export function OperationsStats({
|
|||
targetSlug,
|
||||
},
|
||||
period,
|
||||
filter: {
|
||||
operationIds: operationsFilter,
|
||||
clientNames: clientNamesFilter,
|
||||
},
|
||||
filter,
|
||||
resolution,
|
||||
},
|
||||
});
|
||||
|
|
@ -1090,7 +1086,7 @@ export function OperationsStats({
|
|||
if (!query.fetching) {
|
||||
refetch();
|
||||
}
|
||||
}, [period]);
|
||||
}, [period, filter]);
|
||||
|
||||
const isFetching = query.fetching;
|
||||
const isError = !!query.error;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,233 @@
|
|||
import { useCallback, useState } from 'react';
|
||||
import { useMutation } from 'urql';
|
||||
import type { SavedFilterView } from '@/components/base/insights-filters';
|
||||
import { Popover } from '@/components/base/popover/popover';
|
||||
import { TriggerButton } from '@/components/base/trigger-button';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { graphql } from '@/gql';
|
||||
import { SavedFilterVisibilityType } from '@/gql/graphql';
|
||||
import { UpdateFilterButton } from './update-filter-button';
|
||||
|
||||
const InsightsCreateSavedFilter_Mutation = graphql(`
|
||||
mutation InsightsCreateSavedFilter($input: CreateSavedFilterInput!) {
|
||||
createSavedFilter(input: $input) {
|
||||
error {
|
||||
message
|
||||
}
|
||||
ok {
|
||||
savedFilter {
|
||||
id
|
||||
name
|
||||
visibility
|
||||
viewerCanUpdate
|
||||
filters {
|
||||
operationHashes
|
||||
clientFilters {
|
||||
name
|
||||
versions
|
||||
}
|
||||
dateRange {
|
||||
from
|
||||
to
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
export type CurrentFilters = {
|
||||
operations: string[];
|
||||
clients: Array<{ name: string; versions: string[] | null }>;
|
||||
dateRange: { from: string; to: string };
|
||||
};
|
||||
|
||||
type SaveFilterButtonProps = {
|
||||
activeView: SavedFilterView | null;
|
||||
viewerCanCreate: boolean;
|
||||
viewerCanShare: boolean;
|
||||
currentFilters: CurrentFilters;
|
||||
organizationSlug: string;
|
||||
projectSlug: string;
|
||||
targetSlug: string;
|
||||
onSaved: (viewId: string) => void;
|
||||
onUpdated: () => void;
|
||||
};
|
||||
|
||||
export function SaveFilterButton({
|
||||
activeView,
|
||||
viewerCanCreate,
|
||||
viewerCanShare,
|
||||
currentFilters,
|
||||
organizationSlug,
|
||||
projectSlug,
|
||||
targetSlug,
|
||||
onSaved,
|
||||
onUpdated,
|
||||
}: SaveFilterButtonProps) {
|
||||
if (activeView?.viewerCanUpdate) {
|
||||
return (
|
||||
<UpdateFilterButton
|
||||
activeView={activeView}
|
||||
currentFilters={currentFilters}
|
||||
organizationSlug={organizationSlug}
|
||||
projectSlug={projectSlug}
|
||||
targetSlug={targetSlug}
|
||||
onUpdated={onUpdated}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (viewerCanCreate) {
|
||||
return (
|
||||
<SaveFilterPopover
|
||||
viewerCanShare={viewerCanShare}
|
||||
currentFilters={currentFilters}
|
||||
organizationSlug={organizationSlug}
|
||||
projectSlug={projectSlug}
|
||||
targetSlug={targetSlug}
|
||||
onSaved={onSaved}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function SaveFilterPopover({
|
||||
viewerCanShare,
|
||||
currentFilters,
|
||||
organizationSlug,
|
||||
projectSlug,
|
||||
targetSlug,
|
||||
onSaved,
|
||||
}: {
|
||||
viewerCanShare: boolean;
|
||||
currentFilters: CurrentFilters;
|
||||
organizationSlug: string;
|
||||
projectSlug: string;
|
||||
targetSlug: string;
|
||||
onSaved: (viewId: string) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [name, setName] = useState('');
|
||||
const [visibility, setVisibility] = useState<SavedFilterVisibilityType>(
|
||||
SavedFilterVisibilityType.Private,
|
||||
);
|
||||
const [createResult, createSavedFilter] = useMutation(InsightsCreateSavedFilter_Mutation);
|
||||
const { toast } = useToast();
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
const result = await createSavedFilter({
|
||||
input: {
|
||||
target: { bySelector: { organizationSlug, projectSlug, targetSlug } },
|
||||
name: trimmed,
|
||||
visibility,
|
||||
insightsFilter: {
|
||||
operationHashes: currentFilters.operations,
|
||||
clientFilters: currentFilters.clients.map(c => ({
|
||||
name: c.name,
|
||||
versions: c.versions,
|
||||
})),
|
||||
dateRange: {
|
||||
from: currentFilters.dateRange.from,
|
||||
to: currentFilters.dateRange.to,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (result.data?.createSavedFilter.ok) {
|
||||
toast({
|
||||
title: 'Filter saved',
|
||||
description: `"${trimmed}" has been saved.`,
|
||||
});
|
||||
setOpen(false);
|
||||
setName('');
|
||||
setVisibility(SavedFilterVisibilityType.Private);
|
||||
onSaved(result.data.createSavedFilter.ok.savedFilter.id);
|
||||
} else {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Error',
|
||||
description:
|
||||
result.data?.createSavedFilter.error?.message ??
|
||||
result.error?.message ??
|
||||
'Failed to save filter.',
|
||||
});
|
||||
}
|
||||
}, [
|
||||
name,
|
||||
visibility,
|
||||
currentFilters,
|
||||
organizationSlug,
|
||||
projectSlug,
|
||||
targetSlug,
|
||||
createSavedFilter,
|
||||
toast,
|
||||
onSaved,
|
||||
]);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
align="start"
|
||||
title="Save to filter collections"
|
||||
trigger={<TriggerButton label="Save this filter view" variant="action" />}
|
||||
content={
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Input
|
||||
placeholder="Name this filter collection"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' && name.trim()) {
|
||||
void handleSave();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Select
|
||||
value={visibility}
|
||||
onValueChange={v => setVisibility(v as SavedFilterVisibilityType)}
|
||||
>
|
||||
<SelectTrigger variant="inset">
|
||||
<SelectValue placeholder="Save location" />
|
||||
</SelectTrigger>
|
||||
<SelectContent variant="inset">
|
||||
<SelectItem value={SavedFilterVisibilityType.Private}>My views</SelectItem>
|
||||
{viewerCanShare && (
|
||||
<SelectItem value={SavedFilterVisibilityType.Shared}>Shared views</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
onClick={() => void handleSave()}
|
||||
disabled={!name.trim() || createResult.fetching}
|
||||
>
|
||||
Save filter
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,163 @@
|
|||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useMutation } from 'urql';
|
||||
import type { SavedFilterView } from '@/components/base/insights-filters';
|
||||
import { Popover } from '@/components/base/popover/popover';
|
||||
import { TriggerButton } from '@/components/base/trigger-button';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { graphql } from '@/gql';
|
||||
import type { CurrentFilters } from './save-filter-button';
|
||||
|
||||
const InsightsUpdateSavedFilter_Mutation = graphql(`
|
||||
mutation InsightsUpdateSavedFilter($input: UpdateSavedFilterInput!) {
|
||||
updateSavedFilter(input: $input) {
|
||||
error {
|
||||
message
|
||||
}
|
||||
ok {
|
||||
savedFilter {
|
||||
id
|
||||
name
|
||||
visibility
|
||||
viewerCanUpdate
|
||||
filters {
|
||||
operationHashes
|
||||
clientFilters {
|
||||
name
|
||||
versions
|
||||
}
|
||||
dateRange {
|
||||
from
|
||||
to
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
type UpdateFilterButtonProps = {
|
||||
activeView: SavedFilterView;
|
||||
currentFilters: CurrentFilters;
|
||||
organizationSlug: string;
|
||||
projectSlug: string;
|
||||
targetSlug: string;
|
||||
onUpdated: () => void;
|
||||
};
|
||||
|
||||
export function UpdateFilterButton({
|
||||
activeView,
|
||||
currentFilters,
|
||||
organizationSlug,
|
||||
projectSlug,
|
||||
targetSlug,
|
||||
onUpdated,
|
||||
}: UpdateFilterButtonProps) {
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [updateResult, updateSavedFilter] = useMutation(InsightsUpdateSavedFilter_Mutation);
|
||||
const { toast } = useToast();
|
||||
|
||||
const hasUnsavedChanges = useMemo(() => {
|
||||
const savedOps = [...activeView.filters.operationHashes].sort();
|
||||
const currentOps = [...currentFilters.operations].sort();
|
||||
|
||||
if (JSON.stringify(currentOps) !== JSON.stringify(savedOps)) return true;
|
||||
|
||||
const normalizeClients = (arr: Array<{ name: string; versions: string[] | null }>) =>
|
||||
JSON.stringify(
|
||||
arr
|
||||
.map(c => ({ name: c.name, versions: c.versions }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
);
|
||||
if (
|
||||
normalizeClients(currentFilters.clients) !==
|
||||
normalizeClients(activeView.filters.clientFilters)
|
||||
)
|
||||
return true;
|
||||
|
||||
const savedFrom = activeView.filters.dateRange?.from;
|
||||
const savedTo = activeView.filters.dateRange?.to;
|
||||
if (currentFilters.dateRange.from !== savedFrom || currentFilters.dateRange.to !== savedTo)
|
||||
return true;
|
||||
|
||||
return false;
|
||||
}, [activeView, currentFilters]);
|
||||
|
||||
const handleUpdate = useCallback(async () => {
|
||||
const result = await updateSavedFilter({
|
||||
input: {
|
||||
id: activeView.id,
|
||||
target: { bySelector: { organizationSlug, projectSlug, targetSlug } },
|
||||
insightsFilter: {
|
||||
operationHashes: currentFilters.operations,
|
||||
clientFilters: currentFilters.clients.map(c => ({
|
||||
name: c.name,
|
||||
versions: c.versions,
|
||||
})),
|
||||
dateRange: {
|
||||
from: currentFilters.dateRange.from,
|
||||
to: currentFilters.dateRange.to,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (result.data?.updateSavedFilter.ok) {
|
||||
toast({
|
||||
title: 'Filter updated',
|
||||
description: `"${activeView.name}" has been updated.`,
|
||||
});
|
||||
setConfirmOpen(false);
|
||||
onUpdated();
|
||||
} else {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Error',
|
||||
description:
|
||||
result.data?.updateSavedFilter.error?.message ??
|
||||
result.error?.message ??
|
||||
'Failed to update filter.',
|
||||
});
|
||||
}
|
||||
}, [
|
||||
activeView,
|
||||
currentFilters,
|
||||
organizationSlug,
|
||||
projectSlug,
|
||||
targetSlug,
|
||||
updateSavedFilter,
|
||||
toast,
|
||||
onUpdated,
|
||||
]);
|
||||
|
||||
if (!hasUnsavedChanges) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={confirmOpen}
|
||||
onOpenChange={setConfirmOpen}
|
||||
align="start"
|
||||
title="Update saved filter"
|
||||
description={`This will overwrite the current configuration of "${activeView.name}" with your current filter selections.`}
|
||||
trigger={<TriggerButton label={`Update "${activeView.name}" filter`} variant="action" />}
|
||||
content={
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
className="flex-1"
|
||||
onClick={() => void handleUpdate()}
|
||||
disabled={updateResult.fetching}
|
||||
>
|
||||
Update filter
|
||||
</Button>
|
||||
<Button variant="outline" className="flex-1" onClick={() => setConfirmOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -15,7 +15,7 @@ const AlertDialogOverlay = React.forwardRef<
|
|||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
className={cn(
|
||||
'bg-neutral-1.01 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 backdrop-blur-sm',
|
||||
'bg-neutral-1_01 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 backdrop-blur-sm',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
130
packages/web/app/src/components/ui/date-range-picker.stories.tsx
Normal file
130
packages/web/app/src/components/ui/date-range-picker.stories.tsx
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import { useState } from 'react';
|
||||
import type { Story, StoryDefault } from '@ladle/react';
|
||||
import { DateRangePicker, presetLast1Day, presetLast7Days, type Preset } from './date-range-picker';
|
||||
|
||||
export default {
|
||||
title: 'UI / DateRangePicker',
|
||||
} satisfies StoryDefault;
|
||||
|
||||
/** Default with all presets and Last 7 days selected (most common usage) */
|
||||
export const Default: Story = () => {
|
||||
const [selectedRange, setSelectedRange] = useState<Preset['range']>(presetLast7Days.range);
|
||||
|
||||
return (
|
||||
<DateRangePicker
|
||||
selectedRange={selectedRange}
|
||||
onUpdate={args => setSelectedRange(args.preset.range)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/** With Last 24 hours selected (used in operation detail views) */
|
||||
export const Last24Hours: Story = () => {
|
||||
const [selectedRange, setSelectedRange] = useState<Preset['range']>(presetLast1Day.range);
|
||||
|
||||
return (
|
||||
<DateRangePicker
|
||||
selectedRange={selectedRange}
|
||||
onUpdate={args => setSelectedRange(args.preset.range)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/** With restricted valid units — excludes minutes (used in operation insights) */
|
||||
export const RestrictedUnits: Story = () => {
|
||||
const [selectedRange, setSelectedRange] = useState<Preset['range']>(presetLast7Days.range);
|
||||
|
||||
return (
|
||||
<DateRangePicker
|
||||
validUnits={['y', 'M', 'w', 'd', 'h']}
|
||||
selectedRange={selectedRange}
|
||||
onUpdate={args => setSelectedRange(args.preset.range)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/** All units enabled with startDate and end-aligned popover (used in manage/admin pages) */
|
||||
export const AllUnitsWithStartDate: Story = () => {
|
||||
const [selectedRange, setSelectedRange] = useState<Preset['range']>(presetLast7Days.range);
|
||||
const startDate = new Date();
|
||||
startDate.setFullYear(startDate.getFullYear() - 1);
|
||||
|
||||
return (
|
||||
<DateRangePicker
|
||||
validUnits={['y', 'M', 'w', 'd', 'h', 'm']}
|
||||
selectedRange={selectedRange}
|
||||
startDate={startDate}
|
||||
align="end"
|
||||
onUpdate={args => setSelectedRange(args.preset.range)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/** With a custom absolute date range selected */
|
||||
export const AbsoluteDateRange: Story = () => {
|
||||
const from = new Date();
|
||||
from.setDate(from.getDate() - 3);
|
||||
const to = new Date();
|
||||
|
||||
const absoluteRange = {
|
||||
from: from.toISOString(),
|
||||
to: to.toISOString(),
|
||||
};
|
||||
|
||||
const [selectedRange, setSelectedRange] = useState<Preset['range']>(absoluteRange);
|
||||
|
||||
return (
|
||||
<DateRangePicker
|
||||
selectedRange={selectedRange}
|
||||
onUpdate={args => setSelectedRange(args.preset.range)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/** With custom presets instead of the defaults */
|
||||
export const CustomPresets: Story = () => {
|
||||
const customPresets: Preset[] = [
|
||||
{ name: 'last1h', label: 'Last 1 hour', range: { from: 'now-1h', to: 'now' } },
|
||||
{ name: 'last6h', label: 'Last 6 hours', range: { from: 'now-6h', to: 'now' } },
|
||||
presetLast1Day,
|
||||
presetLast7Days,
|
||||
];
|
||||
|
||||
const [selectedRange, setSelectedRange] = useState<Preset['range']>(customPresets[0].range);
|
||||
|
||||
return (
|
||||
<DateRangePicker
|
||||
presets={customPresets}
|
||||
selectedRange={selectedRange}
|
||||
onUpdate={args => setSelectedRange(args.preset.range)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/** With no initial selection */
|
||||
export const NoSelection: Story = () => {
|
||||
const [selectedRange, setSelectedRange] = useState<Preset['range'] | null>(null);
|
||||
|
||||
return (
|
||||
<DateRangePicker
|
||||
selectedRange={selectedRange}
|
||||
onUpdate={args => setSelectedRange(args.preset.range)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/** With a startDate that disables older presets */
|
||||
export const RecentStartDate: Story = () => {
|
||||
const startDate = new Date();
|
||||
startDate.setDate(startDate.getDate() - 7);
|
||||
|
||||
const [selectedRange, setSelectedRange] = useState<Preset['range']>(presetLast1Day.range);
|
||||
|
||||
return (
|
||||
<DateRangePicker
|
||||
selectedRange={selectedRange}
|
||||
startDate={startDate}
|
||||
onUpdate={args => setSelectedRange(args.preset.range)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -14,7 +14,7 @@ import { Button } from './button';
|
|||
import { Calendar } from './calendar';
|
||||
import { Input } from './input';
|
||||
import { Label } from './label';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from './popover';
|
||||
import { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } from './popover';
|
||||
|
||||
export interface DateRangePickerProps {
|
||||
presets?: Preset[];
|
||||
|
|
@ -24,12 +24,30 @@ export interface DateRangePickerProps {
|
|||
onUpdate?: (values: { preset: Preset }) => void;
|
||||
/** Alignment of popover */
|
||||
align?: 'start' | 'center' | 'end';
|
||||
/** Side of the trigger to place the popover content */
|
||||
side?: 'top' | 'bottom' | 'left' | 'right';
|
||||
/** Option for locale */
|
||||
locale?: string;
|
||||
/** Date after which a range can be picked. */
|
||||
startDate?: Date;
|
||||
/** valid units allowed */
|
||||
validUnits?: DurationUnit[];
|
||||
/** Custom trigger element. Must forward ref. Replaces the default Button trigger. */
|
||||
trigger?: React.ReactElement;
|
||||
}
|
||||
|
||||
export interface DateRangePickerPanelProps {
|
||||
presets?: Preset[];
|
||||
/** the active selected/custom preset */
|
||||
selectedRange?: { from: string; to: string } | null;
|
||||
/** Click handler for applying the updates from DateRangePicker. */
|
||||
onUpdate?: (values: { preset: Preset }) => void;
|
||||
/** Date after which a range can be picked. */
|
||||
startDate?: Date;
|
||||
/** valid units allowed */
|
||||
validUnits?: DurationUnit[];
|
||||
/** Called when a selection is made. Parent should close the container (popover, submenu, etc). */
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
interface ResolvedDateRange {
|
||||
|
|
@ -154,8 +172,40 @@ export function findMatchingPreset(
|
|||
});
|
||||
}
|
||||
|
||||
/** The DateRangePicker component allows a user to select a range of dates */
|
||||
export function DateRangePicker(props: DateRangePickerProps): JSX.Element {
|
||||
function getDisplayLabel(
|
||||
selectedRange: { from: string; to: string } | null | undefined,
|
||||
presets: Preset[],
|
||||
validUnits: DurationUnit[],
|
||||
): string {
|
||||
if (!selectedRange) {
|
||||
return presets.at(0)?.label ?? 'Select range';
|
||||
}
|
||||
|
||||
const staticMatch = findMatchingPreset(selectedRange, presets);
|
||||
if (staticMatch) return staticMatch.label;
|
||||
|
||||
if (selectedRange.from.startsWith('now-')) {
|
||||
const number = parseInt(selectedRange.from.replace(/\D/g, ''), 10);
|
||||
if (!Number.isNaN(number)) {
|
||||
const dynamicMatch = findMatchingPreset(
|
||||
selectedRange,
|
||||
createQuickRangePresets(number, validUnits),
|
||||
);
|
||||
if (dynamicMatch) return dynamicMatch.label;
|
||||
}
|
||||
}
|
||||
|
||||
const resolved = resolveRange(selectedRange.from, selectedRange.to);
|
||||
if (resolved) return buildDateRangeString(resolved);
|
||||
|
||||
return presets.at(0)?.label ?? 'Select range';
|
||||
}
|
||||
|
||||
/**
|
||||
* The standalone date range picker panel containing all picker UI and state.
|
||||
* Can be rendered inside a Popover, a menu submenu, or any other container.
|
||||
*/
|
||||
export function DateRangePickerPanel(props: DateRangePickerPanelProps) {
|
||||
const validUnits = props.validUnits ?? units;
|
||||
const disallowedUnits = units.filter(unit => !validUnits.includes(unit));
|
||||
const hasInvalidUnitRegex = disallowedUnits?.length
|
||||
|
|
@ -183,7 +233,6 @@ export function DateRangePicker(props: DateRangePickerProps): JSX.Element {
|
|||
});
|
||||
}
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [showCalendar, setShowCalendar] = useState(false);
|
||||
|
||||
function getInitialPreset() {
|
||||
|
|
@ -272,10 +321,6 @@ export function DateRangePicker(props: DateRangePickerProps): JSX.Element {
|
|||
lastPreset.current = activePreset;
|
||||
}, [activePreset]);
|
||||
|
||||
const resetValues = (): void => {
|
||||
setActivePreset(getInitialPreset());
|
||||
};
|
||||
|
||||
const PresetButton = useMemo(
|
||||
() =>
|
||||
function PresetButton({ preset }: { preset: Preset }): React.ReactNode {
|
||||
|
|
@ -306,8 +351,8 @@ export function DateRangePicker(props: DateRangePickerProps): JSX.Element {
|
|||
setToValue(preset.range.to);
|
||||
setRange(undefined);
|
||||
setShowCalendar(false);
|
||||
setIsOpen(false);
|
||||
setQuickRangeFilter('');
|
||||
props.onClose?.();
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
className="w-full justify-start text-left"
|
||||
|
|
@ -316,7 +361,7 @@ export function DateRangePicker(props: DateRangePickerProps): JSX.Element {
|
|||
</Button>
|
||||
);
|
||||
},
|
||||
[props.startDate],
|
||||
[props.startDate, props.onClose],
|
||||
);
|
||||
|
||||
const dynamicPresets = useMemo(() => {
|
||||
|
|
@ -341,190 +386,226 @@ export function DateRangePicker(props: DateRangePickerProps): JSX.Element {
|
|||
return [];
|
||||
}, [quickRangeFilter, validUnits]);
|
||||
|
||||
return (
|
||||
<div className="flex h-[380px]">
|
||||
<Popover modal open={showCalendar} onOpenChange={setShowCalendar}>
|
||||
<PopoverAnchor asChild>
|
||||
<div className="flex flex-col py-2">
|
||||
<div className="flex flex-col items-center justify-end gap-2 lg:flex-row lg:items-start">
|
||||
<div className="flex flex-col gap-1 pl-3">
|
||||
<div className="mb-2 mt-1 text-sm">Absolute date range</div>
|
||||
<div className="space-y-2">
|
||||
<div className="grid w-full max-w-sm items-center gap-1.5">
|
||||
<Label htmlFor="from" className="text-neutral-10 text-xs">
|
||||
From
|
||||
</Label>
|
||||
<div className="flex w-full max-w-sm items-center space-x-2">
|
||||
<div className="relative flex w-full">
|
||||
<Input
|
||||
type="text"
|
||||
id="from"
|
||||
value={fromValue}
|
||||
onChange={ev => {
|
||||
setFromValue(ev.target.value);
|
||||
}}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute right-2 top-1/2 size-6 -translate-y-1/2 px-0"
|
||||
onClick={() => setShowCalendar(true)}
|
||||
>
|
||||
<CalendarDays className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-red-500">
|
||||
{hasInvalidUnitRegex?.test(fromValue) ? (
|
||||
<>Only allowed units are {validUnits.join(', ')}</>
|
||||
) : !fromParsed ? (
|
||||
<>Invalid date string</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid w-full max-w-sm items-center gap-1.5">
|
||||
<Label htmlFor="to" className="text-neutral-10 text-xs">
|
||||
To
|
||||
</Label>
|
||||
<div className="flex w-full max-w-sm items-center space-x-2">
|
||||
<div className="relative flex w-full">
|
||||
<Input
|
||||
type="text"
|
||||
id="to"
|
||||
value={toValue}
|
||||
onChange={ev => {
|
||||
setToValue(ev.target.value);
|
||||
}}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute right-2 top-1/2 size-6 -translate-y-1/2 px-0"
|
||||
onClick={() => setShowCalendar(true)}
|
||||
>
|
||||
<CalendarDays className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-red-500">
|
||||
{hasInvalidUnitRegex?.test(toValue) ? (
|
||||
<>Only allowed units are {validUnits.join(', ')}</>
|
||||
) : !toParsed ? (
|
||||
<>Invalid date string</>
|
||||
) : fromParsed && toParsed && fromParsed.getTime() > toParsed.getTime() ? (
|
||||
<div className="text-red-500">To cannot be before from.</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
className="w-full text-center"
|
||||
onClick={() => {
|
||||
const fromWithoutWhitespace = fromValue.trim();
|
||||
const toWithoutWhitespace = toValue.trim();
|
||||
const resolvedRange = resolveRange(fromValue, toValue);
|
||||
if (resolvedRange) {
|
||||
setActivePreset(
|
||||
() =>
|
||||
findMatchingPreset(
|
||||
{
|
||||
from: fromWithoutWhitespace,
|
||||
to: toWithoutWhitespace,
|
||||
},
|
||||
availablePresets,
|
||||
) ?? {
|
||||
name: `${fromWithoutWhitespace}_${toWithoutWhitespace}`,
|
||||
label: buildDateRangeString(resolvedRange),
|
||||
range: { from: fromWithoutWhitespace, to: toWithoutWhitespace },
|
||||
},
|
||||
);
|
||||
setShowCalendar(false);
|
||||
setQuickRangeFilter('');
|
||||
props.onClose?.();
|
||||
}
|
||||
}}
|
||||
disabled={
|
||||
!toParsed ||
|
||||
!fromParsed ||
|
||||
(activePreset?.range.from === fromValue.trim() &&
|
||||
activePreset.range.to === toValue.trim())
|
||||
}
|
||||
>
|
||||
Apply date range
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverAnchor>
|
||||
<PopoverContent side="left" sideOffset={4} collisionPadding={8} className="w-auto">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="absolute right-2 top-1 rounded-sm bg-transparent opacity-70 transition-opacity hover:bg-transparent hover:opacity-100 focus:outline-none"
|
||||
onClick={() => setShowCalendar(false)}
|
||||
>
|
||||
<Cross1Icon className="size-2" />
|
||||
</Button>
|
||||
<Calendar
|
||||
id="selectedRange"
|
||||
mode="range"
|
||||
defaultMonth={subMonths(new Date(), 1)}
|
||||
numberOfMonths={2}
|
||||
selected={range}
|
||||
onSelect={range => {
|
||||
if (range?.from && range.to) {
|
||||
setFromValue(formatDateToString(range.from));
|
||||
setToValue(formatDateToString(endOfDay(range.to)));
|
||||
}
|
||||
setRange(range);
|
||||
}}
|
||||
disabled={disabledDays}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<div className="ml-3 flex flex-col gap-1 border-l py-2 pl-3 pr-2">
|
||||
<div className="relative flex items-center">
|
||||
<MagnifyingGlassIcon className="absolute left-2" />
|
||||
<Input
|
||||
placeholder="Filter quick ranges"
|
||||
className="w-full pl-7"
|
||||
value={quickRangeFilter}
|
||||
onChange={ev => setQuickRangeFilter(ev.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full flex-1 flex-col items-start gap-1 overflow-y-scroll pb-2 pt-1">
|
||||
{dynamicPresets.length > 0
|
||||
? dynamicPresets
|
||||
.filter(preset =>
|
||||
preset.label.toLowerCase().includes(quickRangeFilter.toLowerCase().trim()),
|
||||
)
|
||||
.map(preset => <PresetButton key={preset.name} preset={preset} />)
|
||||
: staticPresets
|
||||
.filter(preset =>
|
||||
preset.label.toLowerCase().includes(quickRangeFilter.toLowerCase().trim()),
|
||||
)
|
||||
.map(preset => <PresetButton key={preset.name} preset={preset} />)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The DateRangePicker component allows a user to select a range of dates */
|
||||
export function DateRangePicker(props: DateRangePickerProps): JSX.Element {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const validUnits = props.validUnits ?? units;
|
||||
const disallowedUnits = units.filter(unit => !validUnits.includes(unit));
|
||||
const hasInvalidUnitRegex = disallowedUnits?.length
|
||||
? new RegExp(`[0-9]+(${disallowedUnits.join('|')})`)
|
||||
: null;
|
||||
|
||||
let staticPresets = props.presets ?? availablePresets;
|
||||
if (hasInvalidUnitRegex) {
|
||||
staticPresets = staticPresets.filter(
|
||||
preset =>
|
||||
!hasInvalidUnitRegex.test(preset.range.from) && !hasInvalidUnitRegex.test(preset.range.to),
|
||||
);
|
||||
}
|
||||
|
||||
const label = getDisplayLabel(props.selectedRange, staticPresets, validUnits);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
modal
|
||||
open={isOpen}
|
||||
onOpenChange={(open: boolean) => {
|
||||
if (!open) {
|
||||
resetValues();
|
||||
}
|
||||
setIsOpen(open);
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline">
|
||||
{activePreset?.label}
|
||||
<div className="-mr-2 scale-125 pl-1 opacity-60">
|
||||
{isOpen ? <ChevronUpIcon width={24} /> : <ChevronDownIcon width={24} />}
|
||||
</div>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align={props.align} className="mt-1 flex h-[380px] w-auto p-0">
|
||||
<div className="flex flex-col py-2">
|
||||
<div className="flex flex-col items-center justify-end gap-2 lg:flex-row lg:items-start">
|
||||
<div className="flex flex-col gap-1 pl-3">
|
||||
<div className="mb-2 text-sm">Absolute date range</div>
|
||||
<div className="space-y-2">
|
||||
<div className="grid w-full max-w-sm items-center gap-1.5">
|
||||
<Label htmlFor="from" className="text-neutral-10 text-xs">
|
||||
From
|
||||
</Label>
|
||||
<div className="flex w-full max-w-sm items-center space-x-2">
|
||||
<div className="relative flex w-full">
|
||||
<Input
|
||||
type="text"
|
||||
id="from"
|
||||
value={fromValue}
|
||||
onChange={ev => {
|
||||
setFromValue(ev.target.value);
|
||||
}}
|
||||
className="font-mono"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute right-2 top-1/2 size-6 -translate-y-1/2 px-0"
|
||||
onClick={() => setShowCalendar(true)}
|
||||
>
|
||||
<CalendarDays className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-red-500">
|
||||
{hasInvalidUnitRegex?.test(fromValue) ? (
|
||||
<>Only allowed units are {validUnits.join(', ')}</>
|
||||
) : !fromParsed ? (
|
||||
<>Invalid date string</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid w-full max-w-sm items-center gap-1.5">
|
||||
<Label htmlFor="to" className="text-neutral-10 text-xs">
|
||||
To
|
||||
</Label>
|
||||
<div className="flex w-full max-w-sm items-center space-x-2">
|
||||
<div className="relative flex w-full">
|
||||
<Input
|
||||
type="text"
|
||||
id="to"
|
||||
value={toValue}
|
||||
onChange={ev => {
|
||||
setToValue(ev.target.value);
|
||||
}}
|
||||
className="font-mono"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute right-2 top-1/2 size-6 -translate-y-1/2 px-0"
|
||||
onClick={() => setShowCalendar(true)}
|
||||
>
|
||||
<CalendarDays className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-red-500">
|
||||
{hasInvalidUnitRegex?.test(toValue) ? (
|
||||
<>Only allowed units are {validUnits.join(', ')}</>
|
||||
) : !toParsed ? (
|
||||
<>Invalid date string</>
|
||||
) : fromParsed && toParsed && fromParsed.getTime() > toParsed.getTime() ? (
|
||||
<div className="text-red-500">To cannot be before from.</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full text-center"
|
||||
onClick={() => {
|
||||
const fromWithoutWhitespace = fromValue.trim();
|
||||
const toWithoutWhitespace = toValue.trim();
|
||||
const resolvedRange = resolveRange(fromValue, toValue);
|
||||
if (resolvedRange) {
|
||||
setActivePreset(
|
||||
() =>
|
||||
findMatchingPreset(
|
||||
{
|
||||
from: fromWithoutWhitespace,
|
||||
to: toWithoutWhitespace,
|
||||
},
|
||||
availablePresets,
|
||||
) ?? {
|
||||
name: `${fromWithoutWhitespace}_${toWithoutWhitespace}`,
|
||||
label: buildDateRangeString(resolvedRange),
|
||||
range: { from: fromWithoutWhitespace, to: toWithoutWhitespace },
|
||||
},
|
||||
);
|
||||
setIsOpen(false);
|
||||
setShowCalendar(false);
|
||||
setQuickRangeFilter('');
|
||||
}
|
||||
}}
|
||||
disabled={
|
||||
!toParsed ||
|
||||
!fromParsed ||
|
||||
(activePreset?.range.from === fromValue.trim() &&
|
||||
activePreset.range.to === toValue.trim())
|
||||
}
|
||||
>
|
||||
Apply date range
|
||||
</Button>
|
||||
</div>
|
||||
{props.trigger ? (
|
||||
<PopoverTrigger asChild>{props.trigger}</PopoverTrigger>
|
||||
) : (
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline">
|
||||
{label}
|
||||
<div className="-mr-2 scale-125 pl-1 opacity-60">
|
||||
{isOpen ? <ChevronUpIcon width={24} /> : <ChevronDownIcon width={24} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-3 flex flex-col gap-1 border-l py-2 pl-3 pr-2">
|
||||
<div className="relative flex items-center">
|
||||
<MagnifyingGlassIcon className="absolute left-2" />
|
||||
<Input
|
||||
placeholder="Filter quick ranges"
|
||||
className="w-full pl-7"
|
||||
value={quickRangeFilter}
|
||||
onChange={ev => setQuickRangeFilter(ev.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full flex-1 flex-col items-start gap-1 overflow-y-scroll pb-2 pt-1">
|
||||
{dynamicPresets.length > 0
|
||||
? dynamicPresets
|
||||
.filter(preset =>
|
||||
preset.label.toLowerCase().includes(quickRangeFilter.toLowerCase().trim()),
|
||||
)
|
||||
.map(preset => <PresetButton key={preset.name} preset={preset} />)
|
||||
: staticPresets
|
||||
.filter(preset =>
|
||||
preset.label.toLowerCase().includes(quickRangeFilter.toLowerCase().trim()),
|
||||
)
|
||||
.map(preset => <PresetButton key={preset.name} preset={preset} />)}
|
||||
</div>
|
||||
</div>
|
||||
{showCalendar && (
|
||||
<div className="absolute left-0 top-[4px] -translate-x-full">
|
||||
<div className="bg-neutral-4 mr-1 rounded-md border p-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="absolute right-2 top-1 rounded-sm bg-transparent opacity-70 transition-opacity hover:bg-transparent hover:opacity-100 focus:outline-none"
|
||||
onClick={() => setShowCalendar(false)}
|
||||
>
|
||||
<Cross1Icon className="size-2" />
|
||||
</Button>
|
||||
<Calendar
|
||||
id="selectedRange"
|
||||
mode="range"
|
||||
defaultMonth={subMonths(new Date(), 1)}
|
||||
numberOfMonths={2}
|
||||
selected={range}
|
||||
onSelect={range => {
|
||||
if (range?.from && range.to) {
|
||||
setFromValue(formatDateToString(range.from));
|
||||
setToValue(formatDateToString(endOfDay(range.to)));
|
||||
}
|
||||
setRange(range);
|
||||
}}
|
||||
disabled={disabledDays}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
)}
|
||||
<PopoverContent align={props.align} side={props.side} className="mt-1 w-auto p-0">
|
||||
<DateRangePickerPanel
|
||||
presets={props.presets}
|
||||
selectedRange={props.selectedRange}
|
||||
onUpdate={props.onUpdate}
|
||||
startDate={props.startDate}
|
||||
validUnits={props.validUnits}
|
||||
onClose={() => setIsOpen(false)}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ const DialogOverlay = React.forwardRef<
|
|||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'bg-neutral-1.01 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 backdrop-blur-sm',
|
||||
'bg-neutral-1_01 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 backdrop-blur-sm',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
1081
packages/web/app/src/components/ui/popover-usages.stories.tsx
Normal file
1081
packages/web/app/src/components/ui/popover-usages.stories.tsx
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -49,4 +49,6 @@ const PopoverArrow = React.forwardRef<
|
|||
|
||||
PopoverArrow.displayName = PopoverPrimitive.Arrow.displayName;
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverArrow };
|
||||
const PopoverAnchor = PopoverPrimitive.Anchor;
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverArrow, PopoverAnchor };
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ const selectVariants = cva(
|
|||
variant: {
|
||||
default:
|
||||
'hover:text-neutral-12 text-neutral-11 hover:bg-neutral-3 dark:hover:bg-neutral-5 bg-neutral-2 dark:bg-neutral-4 hover:border-neutral-6 border-neutral-5 border ring-offset-neutral-2 placeholder:text-neutral-10 focus:outline-none focus:ring-1 focus:ring-ring focus:ring-offset-1',
|
||||
inset:
|
||||
'bg-transparent hover:bg-neutral-3 dark:hover:bg-neutral-5 text-neutral-11 hover:text-neutral-12 border-neutral-5 border ring-offset-neutral-2 placeholder:text-neutral-10 focus:outline-none focus:ring-1 focus:ring-ring focus:ring-offset-1',
|
||||
ghost: '',
|
||||
},
|
||||
},
|
||||
|
|
@ -46,15 +48,33 @@ const SelectTrigger = React.forwardRef<
|
|||
));
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||
|
||||
const selectContentVariants = cva(
|
||||
'animate-in fade-in-80 relative z-50 min-w-[8rem] cursor-pointer overflow-hidden rounded-md border shadow-md',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'border-neutral-4 bg-neutral-3 dark:bg-neutral-4 dark:border-neutral-5',
|
||||
inset: 'border-neutral-4 bg-neutral-3 dark:bg-neutral-3 dark:border-neutral-4',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
type SelectContentProps = React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content> &
|
||||
VariantProps<typeof selectContentVariants>;
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = 'popper', ...props }, ref) => (
|
||||
SelectContentProps
|
||||
>(({ className, children, position = 'popper', variant, ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'border-neutral-4 bg-neutral-3 dark:bg-neutral-4 dark:border-neutral-5 animate-in fade-in-80 relative z-50 min-w-[8rem] cursor-pointer overflow-hidden rounded-md border shadow-md',
|
||||
selectContentVariants({ variant }),
|
||||
position === 'popper' && 'translate-y-1',
|
||||
className,
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ const SheetOverlay = React.forwardRef<
|
|||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Overlay
|
||||
className={cn(
|
||||
'bg-neutral-1.01 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 backdrop-blur-sm',
|
||||
'bg-neutral-1_01 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 backdrop-blur-sm',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
83
packages/web/app/src/components/ui/toast.stories.tsx
Normal file
83
packages/web/app/src/components/ui/toast.stories.tsx
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import type { Story, StoryDefault } from '@ladle/react';
|
||||
import {
|
||||
Toast,
|
||||
ToastAction,
|
||||
ToastClose,
|
||||
ToastDescription,
|
||||
ToastProvider,
|
||||
ToastTitle,
|
||||
ToastViewport,
|
||||
} from './toast';
|
||||
|
||||
export default {
|
||||
title: 'Old / Toast',
|
||||
} satisfies StoryDefault;
|
||||
|
||||
function ToastDemo({
|
||||
variant,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
}: {
|
||||
variant?: 'default' | 'success' | 'destructive' | 'warning';
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ToastProvider>
|
||||
<Toast variant={variant} open forceMount>
|
||||
<div className="grid gap-1">
|
||||
<ToastTitle>{title}</ToastTitle>
|
||||
{description && <ToastDescription>{description}</ToastDescription>}
|
||||
</div>
|
||||
{action && <ToastAction altText="Undo">Undo</ToastAction>}
|
||||
<ToastClose />
|
||||
</Toast>
|
||||
<ToastViewport />
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export const Default: Story = () => (
|
||||
<ToastDemo title="Default toast" description="This is a default toast message." />
|
||||
);
|
||||
|
||||
export const Success: Story = () => (
|
||||
<ToastDemo variant="success" title="Success" description="The saved filter has been updated." />
|
||||
);
|
||||
|
||||
export const Destructive: Story = () => (
|
||||
<ToastDemo
|
||||
variant="destructive"
|
||||
title="Error"
|
||||
description="Something went wrong. Please try again."
|
||||
/>
|
||||
);
|
||||
|
||||
export const Warning: Story = () => (
|
||||
<ToastDemo
|
||||
variant="warning"
|
||||
title="Warning"
|
||||
description="This action may have unintended consequences."
|
||||
/>
|
||||
);
|
||||
|
||||
export const WithAction: Story = () => (
|
||||
<ToastDemo title="Filter deleted" description="The saved filter has been deleted." action />
|
||||
);
|
||||
|
||||
export const TitleOnly: Story = () => <ToastDemo title="Changes saved" />;
|
||||
|
||||
export const AllVariants: Story = () => (
|
||||
<div className="flex flex-col gap-4">
|
||||
<ToastDemo title="Default" description="This is the default variant." />
|
||||
<ToastDemo variant="success" title="Success" description="This is the success variant." />
|
||||
<ToastDemo
|
||||
variant="destructive"
|
||||
title="Destructive"
|
||||
description="This is the destructive variant."
|
||||
/>
|
||||
<ToastDemo variant="warning" title="Warning" description="This is the warning variant." />
|
||||
</div>
|
||||
);
|
||||
|
|
@ -27,6 +27,7 @@ const toastVariants = cva(
|
|||
variants: {
|
||||
variant: {
|
||||
default: 'border bg-neutral-3 text-neutral-11',
|
||||
success: 'group border-green-600 bg-green-600 text-neutral-1',
|
||||
destructive: 'destructive group border-red-500 bg-red-500 text-neutral-12',
|
||||
warning: 'group border-orange-600 bg-orange-500 text-neutral-1',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { ComponentPropsWithRef, ReactElement } from 'react';
|
||||
import { ComponentPropsWithRef, ReactElement, useEffect, useRef } from 'react';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import Highlighter from 'react-highlight-words';
|
||||
import Select, {
|
||||
|
|
@ -9,26 +9,50 @@ import Select, {
|
|||
Props as SelectProps,
|
||||
StylesConfig,
|
||||
} from 'react-select';
|
||||
import { FixedSizeList } from 'react-window';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { SelectOption } from './radix-select';
|
||||
|
||||
const height = 40;
|
||||
const ITEM_HEIGHT = 40;
|
||||
|
||||
function MenuList(props: any): ReactElement {
|
||||
const { options, children, maxHeight, getValue } = props;
|
||||
const [value] = getValue();
|
||||
const initialOffset = options.indexOf(value) * height;
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: children.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => ITEM_HEIGHT,
|
||||
overscan: 5,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const index = options.indexOf(value);
|
||||
if (index > 0) {
|
||||
virtualizer.scrollToIndex(index);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<FixedSizeList
|
||||
width="100%"
|
||||
height={maxHeight}
|
||||
itemCount={children.length}
|
||||
itemSize={height}
|
||||
initialScrollOffset={initialOffset}
|
||||
>
|
||||
{({ index, style }) => <div style={style}>{children[index]}</div>}
|
||||
</FixedSizeList>
|
||||
<div ref={scrollRef} style={{ maxHeight, overflow: 'auto' }}>
|
||||
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
|
||||
{virtualizer.getVirtualItems().map(virtualItem => (
|
||||
<div
|
||||
key={virtualItem.index}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: virtualItem.size,
|
||||
transform: `translateY(${virtualItem.start}px)`,
|
||||
}}
|
||||
>
|
||||
{children[virtualItem.index]}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1 @@
|
|||
export const LAST_VISITED_ORG_KEY = 'lastVisitedOrganization_v2';
|
||||
|
||||
export const CHART_PRIMARY_COLOR = 'rgb(234, 179, 8)';
|
||||
|
|
|
|||
|
|
@ -236,7 +236,7 @@
|
|||
:root {
|
||||
color-scheme: light dark;
|
||||
|
||||
@apply font-sans;
|
||||
@apply font-sans subpixel-antialiased;
|
||||
|
||||
--header-height: 84px;
|
||||
--tabs-navbar-height: 47px;
|
||||
|
|
@ -257,8 +257,13 @@
|
|||
|
||||
--accent: 206 96% 35%;
|
||||
|
||||
--chart-1: 173 58% 39%;
|
||||
--chart-2: 12 76% 61%;
|
||||
--chart-1: 45 93% 47%;
|
||||
--chart-2: 0 86% 60%;
|
||||
--chart-3: 160 82% 39%;
|
||||
--chart-4: 199 89% 48%;
|
||||
--chart-5: 258 90% 66%;
|
||||
--chart-6: 330 81% 60%;
|
||||
--chart-grid: 0 0% 35%;
|
||||
|
||||
--ring: 216 58% 49%;
|
||||
}
|
||||
|
|
@ -279,8 +284,13 @@
|
|||
|
||||
--accent: 48 100% 83%;
|
||||
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 340 75% 55%;
|
||||
--chart-1: 45 93% 52%;
|
||||
--chart-2: 0 86% 65%;
|
||||
--chart-3: 160 82% 49%;
|
||||
--chart-4: 199 89% 58%;
|
||||
--chart-5: 258 90% 72%;
|
||||
--chart-6: 330 81% 68%;
|
||||
--chart-grid: 0 0% 45%;
|
||||
}
|
||||
|
||||
.hive-laboratory {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,10 @@ import { CollectionsQuery } from '@/lib/hooks/laboratory/use-collections';
|
|||
import type { CreateOrganizationMutation } from '@/pages/organization-new';
|
||||
import type { DeleteOrganizationDocument } from '@/pages/organization-settings';
|
||||
import type { DeleteProjectMutation } from '@/pages/project-settings';
|
||||
import {
|
||||
ManageFilters_SavedFiltersQuery,
|
||||
type ManageFilters_DeleteSavedFilterMutation,
|
||||
} from '@/pages/target-insights-manage-filters';
|
||||
import {
|
||||
TokensDocument,
|
||||
type DeleteTargetMutation,
|
||||
|
|
@ -348,6 +352,41 @@ const createOperationInDocumentCollection: TypedDocumentNodeUpdateResolver<
|
|||
);
|
||||
};
|
||||
|
||||
const deleteSavedFilter: TypedDocumentNodeUpdateResolver<
|
||||
typeof ManageFilters_DeleteSavedFilterMutation
|
||||
> = ({ deleteSavedFilter }, args, cache) => {
|
||||
if (!deleteSavedFilter.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selector = args.input.target.bySelector;
|
||||
if (!selector) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateQuery(
|
||||
cache,
|
||||
{
|
||||
query: ManageFilters_SavedFiltersQuery,
|
||||
variables: {
|
||||
organizationSlug: selector.organizationSlug,
|
||||
selector: {
|
||||
organizationSlug: selector.organizationSlug,
|
||||
projectSlug: selector.projectSlug,
|
||||
targetSlug: selector.targetSlug,
|
||||
},
|
||||
},
|
||||
},
|
||||
data => {
|
||||
if (data.target) {
|
||||
data.target.savedFilters.edges = data.target.savedFilters.edges.filter(
|
||||
edge => edge.node.id !== deleteSavedFilter.ok!.deletedId,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
// UpdateResolver
|
||||
export const Mutation = {
|
||||
createOrganization,
|
||||
|
|
@ -365,4 +404,5 @@ export const Mutation = {
|
|||
deleteDocumentCollection,
|
||||
deleteOperationInDocumentCollection,
|
||||
createOperationInDocumentCollection,
|
||||
deleteSavedFilter,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -51,6 +51,8 @@ export const urqlClient = createClient({
|
|||
SchemaCoordinateStats: noKey,
|
||||
ClientStats: noKey,
|
||||
ClientStatsValues: noKey,
|
||||
ClientVersionStatsValues: noKey,
|
||||
InsightsDateRange: noKey,
|
||||
OperationsStats: noKey,
|
||||
OperationStatsValues: noKey,
|
||||
DurationValues: noKey,
|
||||
|
|
@ -90,6 +92,8 @@ export const urqlClient = createClient({
|
|||
ProjectTargetsResourceAssignment: noKey,
|
||||
ProjectResourceAssignment: noKey,
|
||||
BillingConfiguration: noKey,
|
||||
InsightsFilterConfiguration: noKey,
|
||||
ClientFilter: noKey,
|
||||
SchemaChangeMeta: noKey,
|
||||
SchemaCheckMeta: noKey,
|
||||
FieldArgumentDescriptionChanged: noKey,
|
||||
|
|
|
|||
|
|
@ -8,34 +8,83 @@ export function cn(...inputs: ClassValue[]) {
|
|||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
function getNeutralColor() {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue('--color-neutral-12').trim();
|
||||
/** Convert HSL values (h in degrees, s and l in percent) to a hex string. */
|
||||
function hslToHex(h: number, s: number, l: number): string {
|
||||
s /= 100;
|
||||
l /= 100;
|
||||
const a = s * Math.min(l, 1 - l);
|
||||
const f = (n: number) => {
|
||||
const k = (n + h / 30) % 12;
|
||||
const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
|
||||
return Math.round(255 * color)
|
||||
.toString(16)
|
||||
.padStart(2, '0');
|
||||
};
|
||||
return `#${f(0)}${f(8)}${f(4)}`;
|
||||
}
|
||||
|
||||
function readChartStyles() {
|
||||
const s = getComputedStyle(document.documentElement);
|
||||
const textColor = s.getPropertyValue('--color-neutral-12').trim();
|
||||
|
||||
// Convert HSL CSS variables (e.g. "45 93% 47%") to hex strings.
|
||||
// ECharts can't parse modern comma-less HSL like hsl(45 93% 47%),
|
||||
// so we convert to hex which it handles natively.
|
||||
const hex = (name: string) => {
|
||||
const raw = s.getPropertyValue(name).trim();
|
||||
if (!raw) return '#888';
|
||||
const [h, sVal, l] = raw.split(' ').map(v => parseFloat(v));
|
||||
return hslToHex(h, sVal, l);
|
||||
};
|
||||
|
||||
/** Convert a raw HSL CSS variable to an rgba() string with the given alpha. */
|
||||
const rgba = (name: string, alpha: number) => {
|
||||
const raw = s.getPropertyValue(name).trim();
|
||||
if (!raw) return `rgba(128,128,128,${alpha})`;
|
||||
const [h, sVal, l] = raw.split(' ').map(v => parseFloat(v));
|
||||
const hexColor = hslToHex(h, sVal, l);
|
||||
const r = parseInt(hexColor.slice(1, 3), 16);
|
||||
const g = parseInt(hexColor.slice(3, 5), 16);
|
||||
const b = parseInt(hexColor.slice(5, 7), 16);
|
||||
return `rgba(${r},${g},${b},${alpha})`;
|
||||
};
|
||||
|
||||
return {
|
||||
styles: {
|
||||
backgroundColor: 'transparent' as const,
|
||||
textStyle: { color: textColor },
|
||||
legend: { textStyle: { color: textColor } },
|
||||
},
|
||||
colors: {
|
||||
primary: hex('--chart-1'),
|
||||
error: hex('--chart-2'),
|
||||
p75: hex('--chart-3'),
|
||||
p90: hex('--chart-4'),
|
||||
p95: hex('--chart-5'),
|
||||
p99: hex('--chart-6'),
|
||||
grid: hex('--chart-grid'),
|
||||
/** Semi-transparent text color overlay — for label pills on colored surfaces. */
|
||||
overlayBg: rgba('--neutral-12', 0.7),
|
||||
/** Page-background color — for text on overlayBg pills. */
|
||||
overlayText: hex('--neutral-1'),
|
||||
/** Semi-transparent text color — for subtle borders on colored surfaces. */
|
||||
overlayBorder: rgba('--neutral-12', 0.2),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function useChartStyles() {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const [textColor, setTextColor] = useState(() => {
|
||||
// Read CSS variable on initial mount
|
||||
return getNeutralColor();
|
||||
});
|
||||
const [value, setValue] = useState(() => readChartStyles());
|
||||
|
||||
useLayoutEffect(() => {
|
||||
// Use requestAnimationFrame to ensure DOM updates are complete
|
||||
const rafId = requestAnimationFrame(() => {
|
||||
const color = getNeutralColor();
|
||||
setTextColor(color);
|
||||
setValue(readChartStyles());
|
||||
});
|
||||
|
||||
return () => cancelAnimationFrame(rafId);
|
||||
}, [resolvedTheme]);
|
||||
|
||||
return {
|
||||
backgroundColor: 'transparent',
|
||||
textStyle: { color: textColor },
|
||||
legend: {
|
||||
textStyle: { color: textColor },
|
||||
},
|
||||
};
|
||||
return value;
|
||||
}
|
||||
|
||||
// Strings
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ function SubscriptionPageContent(props: { organizationSlug: string }) {
|
|||
|
||||
const organization = useFragment(SubscriptionPage_OrganizationFragment, currentOrganization);
|
||||
const queryForBilling = useFragment(SubscriptionPage_QueryFragment, query.data);
|
||||
const styles = useChartStyles();
|
||||
const { styles } = useChartStyles();
|
||||
|
||||
const monthlyUsage = query.data?.monthlyUsage ?? [];
|
||||
const monthlyUsagePoints: [string, number][] = useMemo(
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ import { EmptyList } from '@/components/ui/empty-list';
|
|||
import { Meta } from '@/components/ui/meta';
|
||||
import { Subtitle, Title } from '@/components/ui/page';
|
||||
import { QueryError } from '@/components/ui/query-error';
|
||||
import { CHART_PRIMARY_COLOR } from '@/constants';
|
||||
import { graphql } from '@/gql';
|
||||
import { formatNumber, formatThroughput, toDecimal } from '@/lib/hooks';
|
||||
import { useDateRangeController } from '@/lib/hooks/use-date-range-controller';
|
||||
|
|
@ -62,7 +61,7 @@ function ClientView(props: {
|
|||
projectSlug: string;
|
||||
targetSlug: string;
|
||||
}) {
|
||||
const styles = useChartStyles();
|
||||
const { styles, colors } = useChartStyles();
|
||||
const dateRangeController = useDateRangeController({
|
||||
dataRetentionInDays: props.dataRetentionInDays,
|
||||
defaultPreset: presetLast7Days,
|
||||
|
|
@ -236,7 +235,7 @@ function ClientView(props: {
|
|||
min: 0,
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
color: '#595959',
|
||||
color: colors.grid,
|
||||
type: 'dashed',
|
||||
},
|
||||
},
|
||||
|
|
@ -251,7 +250,7 @@ function ClientView(props: {
|
|||
name: 'Requests',
|
||||
showSymbol: false,
|
||||
smooth: false,
|
||||
color: CHART_PRIMARY_COLOR,
|
||||
color: colors.primary,
|
||||
areaStyle: {},
|
||||
emphasis: {
|
||||
focus: 'series',
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@ import { Link as LegacyLink } from '@/components/ui/link';
|
|||
import { Meta } from '@/components/ui/meta';
|
||||
import { Subtitle, Title } from '@/components/ui/page';
|
||||
import { QueryError } from '@/components/ui/query-error';
|
||||
import { CHART_PRIMARY_COLOR } from '@/constants';
|
||||
import { graphql } from '@/gql';
|
||||
import { formatNumber, formatThroughput, toDecimal } from '@/lib/hooks';
|
||||
import { useDateRangeController } from '@/lib/hooks/use-date-range-controller';
|
||||
|
|
@ -87,7 +86,7 @@ function SchemaCoordinateView(props: {
|
|||
projectSlug: string;
|
||||
targetSlug: string;
|
||||
}) {
|
||||
const styles = useChartStyles();
|
||||
const { styles, colors } = useChartStyles();
|
||||
const dateRangeController = useDateRangeController({
|
||||
dataRetentionInDays: props.dataRetentionInDays,
|
||||
defaultPreset: presetLast7Days,
|
||||
|
|
@ -294,7 +293,7 @@ function SchemaCoordinateView(props: {
|
|||
min: 0,
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
color: '#595959',
|
||||
color: colors.grid,
|
||||
type: 'dashed',
|
||||
},
|
||||
},
|
||||
|
|
@ -309,7 +308,7 @@ function SchemaCoordinateView(props: {
|
|||
name: 'Requests',
|
||||
showSymbol: false,
|
||||
smooth: false,
|
||||
color: CHART_PRIMARY_COLOR,
|
||||
color: colors.primary,
|
||||
areaStyle: {},
|
||||
emphasis: {
|
||||
focus: 'series',
|
||||
|
|
|
|||
896
packages/web/app/src/pages/target-insights-manage-filters.tsx
Normal file
896
packages/web/app/src/pages/target-insights-manage-filters.tsx
Normal file
|
|
@ -0,0 +1,896 @@
|
|||
import { ReactElement, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { formatDate, formatISO, subDays } from 'date-fns';
|
||||
import { ArrowLeft, ChevronDown, ChevronRight, Lock, MoreVertical, Users } from 'lucide-react';
|
||||
import { useMutation, useQuery } from 'urql';
|
||||
import { FilterDropdown } from '@/components/base/filter-dropdown/filter-dropdown';
|
||||
import type { FilterItem, FilterSelection } from '@/components/base/filter-dropdown/types';
|
||||
import { Menu, MenuItem } from '@/components/base/menu/menu';
|
||||
import { TriggerButton } from '@/components/base/trigger-button';
|
||||
import { Page, TargetLayout } from '@/components/layouts/target';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
availablePresets,
|
||||
buildDateRangeString,
|
||||
DateRangePicker,
|
||||
type Preset,
|
||||
} from '@/components/ui/date-range-picker';
|
||||
import { EmptyList } from '@/components/ui/empty-list';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Meta } from '@/components/ui/meta';
|
||||
import { Subtitle, Title } from '@/components/ui/page';
|
||||
import { QueryError } from '@/components/ui/query-error';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { graphql } from '@/gql';
|
||||
import { SavedFilterVisibilityType } from '@/gql/graphql';
|
||||
import { parse } from '@/lib/date-math';
|
||||
import type { ResultOf } from '@graphql-typed-document-node/core';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
|
||||
export const ManageFilters_SavedFiltersQuery = graphql(`
|
||||
query ManageFilters_SavedFiltersQuery(
|
||||
$selector: TargetSelectorInput!
|
||||
$organizationSlug: String!
|
||||
) {
|
||||
organization: organizationBySlug(organizationSlug: $organizationSlug) {
|
||||
id
|
||||
usageRetentionInDays
|
||||
}
|
||||
target(reference: { bySelector: $selector }) {
|
||||
id
|
||||
savedFilters(first: 50) {
|
||||
edges {
|
||||
cursor
|
||||
node {
|
||||
id
|
||||
name
|
||||
description
|
||||
visibility
|
||||
viewsCount
|
||||
createdAt
|
||||
updatedAt
|
||||
filters {
|
||||
operationHashes
|
||||
clientFilters {
|
||||
name
|
||||
versions
|
||||
}
|
||||
dateRange {
|
||||
from
|
||||
to
|
||||
}
|
||||
}
|
||||
createdBy {
|
||||
id
|
||||
displayName
|
||||
}
|
||||
viewerCanUpdate
|
||||
viewerCanDelete
|
||||
}
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
viewerCanCreateSavedFilter
|
||||
viewerCanShareSavedFilter
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
export const ManageFilters_DeleteSavedFilterMutation = graphql(`
|
||||
mutation ManageFilters_DeleteSavedFilter($input: DeleteSavedFilterInput!) {
|
||||
deleteSavedFilter(input: $input) {
|
||||
error {
|
||||
message
|
||||
}
|
||||
ok {
|
||||
deletedId
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const ManageFilters_UpdateSavedFilterMutation = graphql(`
|
||||
mutation ManageFilters_UpdateSavedFilter($input: UpdateSavedFilterInput!) {
|
||||
updateSavedFilter(input: $input) {
|
||||
error {
|
||||
message
|
||||
}
|
||||
ok {
|
||||
savedFilter {
|
||||
id
|
||||
name
|
||||
filters {
|
||||
operationHashes
|
||||
clientFilters {
|
||||
name
|
||||
versions
|
||||
}
|
||||
dateRange {
|
||||
from
|
||||
to
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const ManageFilters_OperationStatsQuery = graphql(`
|
||||
query ManageFilters_OperationStats($selector: TargetSelectorInput!, $period: DateRangeInput!) {
|
||||
target(reference: { bySelector: $selector }) {
|
||||
id
|
||||
operationsStats(period: $period) {
|
||||
operations {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
name
|
||||
operationHash
|
||||
}
|
||||
}
|
||||
}
|
||||
clients {
|
||||
edges {
|
||||
node {
|
||||
name
|
||||
versions {
|
||||
version
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const DEFAULT_DATE_RANGE = { from: 'now-7d', to: 'now' };
|
||||
|
||||
type SavedFilterNode = NonNullable<
|
||||
ResultOf<typeof ManageFilters_SavedFiltersQuery>['target']
|
||||
>['savedFilters']['edges'][number]['node'];
|
||||
|
||||
function SavedFilterRow({
|
||||
filter,
|
||||
expanded,
|
||||
onToggleExpand,
|
||||
organizationSlug,
|
||||
projectSlug,
|
||||
targetSlug,
|
||||
dataRetentionInDays,
|
||||
}: {
|
||||
filter: SavedFilterNode;
|
||||
expanded: boolean;
|
||||
onToggleExpand: () => void;
|
||||
organizationSlug: string;
|
||||
projectSlug: string;
|
||||
targetSlug: string;
|
||||
dataRetentionInDays: number;
|
||||
}) {
|
||||
const [, deleteSavedFilter] = useMutation(ManageFilters_DeleteSavedFilterMutation);
|
||||
const [updateResult, updateSavedFilter] = useMutation(ManageFilters_UpdateSavedFilterMutation);
|
||||
const { toast } = useToast();
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
const [renameValue, setRenameValue] = useState(filter.name);
|
||||
|
||||
const handleRename = useCallback(async () => {
|
||||
const trimmed = renameValue.trim();
|
||||
if (!trimmed || trimmed === filter.name) return;
|
||||
|
||||
const result = await updateSavedFilter({
|
||||
input: {
|
||||
id: filter.id,
|
||||
target: {
|
||||
bySelector: { organizationSlug, projectSlug, targetSlug },
|
||||
},
|
||||
name: trimmed,
|
||||
},
|
||||
});
|
||||
|
||||
if (result.error || result.data?.updateSavedFilter.error) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Error',
|
||||
description: result.error?.message || result.data?.updateSavedFilter.error?.message,
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: 'Filter renamed',
|
||||
description: 'The saved filter has been renamed.',
|
||||
});
|
||||
setIsRenaming(false);
|
||||
}
|
||||
}, [
|
||||
renameValue,
|
||||
filter.name,
|
||||
filter.id,
|
||||
organizationSlug,
|
||||
projectSlug,
|
||||
targetSlug,
|
||||
updateSavedFilter,
|
||||
toast,
|
||||
]);
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
void deleteSavedFilter({
|
||||
input: {
|
||||
target: {
|
||||
bySelector: {
|
||||
organizationSlug,
|
||||
projectSlug,
|
||||
targetSlug,
|
||||
},
|
||||
},
|
||||
id: filter.id,
|
||||
},
|
||||
}).then(result => {
|
||||
if (result.error || result.data?.deleteSavedFilter.error) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Error',
|
||||
description: result.error?.message || result.data?.deleteSavedFilter.error?.message,
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: 'Filter deleted',
|
||||
description: 'The saved filter has been deleted.',
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [deleteSavedFilter, filter.id, organizationSlug, projectSlug, targetSlug, toast]);
|
||||
|
||||
const ChevronIcon = expanded ? ChevronDown : ChevronRight;
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableRow className="cursor-pointer" onClick={onToggleExpand}>
|
||||
<TableCell className="w-8">
|
||||
<ChevronIcon className="text-neutral-10 size-4" />
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className="font-medium"
|
||||
onClick={isRenaming ? e => e.stopPropagation() : undefined}
|
||||
>
|
||||
{isRenaming ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<Input
|
||||
value={renameValue}
|
||||
onChange={e => setRenameValue(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') {
|
||||
void handleRename();
|
||||
} else if (e.key === 'Escape') {
|
||||
setIsRenaming(false);
|
||||
setRenameValue(filter.name);
|
||||
}
|
||||
}}
|
||||
className="h-8"
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => void handleRename()}
|
||||
disabled={
|
||||
updateResult.fetching || renameValue.trim() === filter.name || !renameValue.trim()
|
||||
}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</span>
|
||||
) : (
|
||||
filter.name
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{filter.viewsCount.toLocaleString()}</TableCell>
|
||||
<TableCell>{formatDate(filter.createdAt, 'MMM d, yyyy')}</TableCell>
|
||||
<TableCell>{formatDate(filter.updatedAt, 'MMM d, yyyy')}</TableCell>
|
||||
<TableCell>
|
||||
<span className="flex items-center gap-1.5">
|
||||
{filter.visibility === SavedFilterVisibilityType.Shared ? (
|
||||
<>
|
||||
<Users className="text-neutral-10 size-4" />
|
||||
Shared
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Lock className="text-neutral-10 size-4" />
|
||||
Private
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className="w-12 text-right"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
{(filter.viewerCanUpdate || filter.viewerCanDelete) && (
|
||||
<Menu
|
||||
trigger={
|
||||
<Button variant="ghost" className="flex size-8 p-0">
|
||||
<MoreVertical className="size-4" />
|
||||
<span className="sr-only">Open menu</span>
|
||||
</Button>
|
||||
}
|
||||
align="end"
|
||||
sections={[
|
||||
[
|
||||
<MenuItem
|
||||
key="view"
|
||||
render={
|
||||
<Link
|
||||
to="/$organizationSlug/$projectSlug/$targetSlug/insights"
|
||||
params={{ organizationSlug, projectSlug, targetSlug }}
|
||||
search={{
|
||||
operations:
|
||||
filter.filters.operationHashes.length > 0
|
||||
? filter.filters.operationHashes
|
||||
: undefined,
|
||||
clients:
|
||||
filter.filters.clientFilters.length > 0
|
||||
? filter.filters.clientFilters.map(c => ({
|
||||
name: c.name,
|
||||
versions: c.versions ?? null,
|
||||
}))
|
||||
: undefined,
|
||||
from: filter.filters.dateRange?.from,
|
||||
to: filter.filters.dateRange?.to,
|
||||
viewId: filter.id,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
View in Insights
|
||||
</MenuItem>,
|
||||
filter.viewerCanUpdate && (
|
||||
<MenuItem
|
||||
key="rename"
|
||||
onClick={() => {
|
||||
setRenameValue(filter.name);
|
||||
setIsRenaming(true);
|
||||
}}
|
||||
>
|
||||
Rename
|
||||
</MenuItem>
|
||||
),
|
||||
filter.viewerCanDelete && (
|
||||
<MenuItem key="delete" variant="destructiveAction" onClick={handleDelete}>
|
||||
Delete
|
||||
</MenuItem>
|
||||
),
|
||||
],
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{expanded && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="px-10 py-4">
|
||||
<SavedFilterRowFilters
|
||||
filter={filter}
|
||||
organizationSlug={organizationSlug}
|
||||
projectSlug={projectSlug}
|
||||
targetSlug={targetSlug}
|
||||
dataRetentionInDays={dataRetentionInDays}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SavedFilterRowFilters({
|
||||
filter,
|
||||
organizationSlug,
|
||||
projectSlug,
|
||||
targetSlug,
|
||||
dataRetentionInDays,
|
||||
}: {
|
||||
filter: SavedFilterNode;
|
||||
organizationSlug: string;
|
||||
projectSlug: string;
|
||||
targetSlug: string;
|
||||
dataRetentionInDays: number;
|
||||
}) {
|
||||
const { operationHashes, clientFilters } = filter.filters;
|
||||
|
||||
// Date range state (relative strings like 'now-7d')
|
||||
const savedDateRange = filter.filters.dateRange ?? DEFAULT_DATE_RANGE;
|
||||
const [dateRange, setDateRange] = useState(savedDateRange);
|
||||
|
||||
const startDate = useMemo(() => subDays(new Date(), dataRetentionInDays), [dataRetentionInDays]);
|
||||
|
||||
const selectedPreset = useMemo<Preset>(() => {
|
||||
const match = availablePresets.find(
|
||||
p => p.range.from === dateRange.from && p.range.to === dateRange.to,
|
||||
);
|
||||
if (match) return match;
|
||||
|
||||
const from = parse(dateRange.from);
|
||||
const to = parse(dateRange.to);
|
||||
if (from && to) {
|
||||
return {
|
||||
name: `${dateRange.from}_${dateRange.to}`,
|
||||
label: buildDateRangeString({ from, to }),
|
||||
range: dateRange,
|
||||
};
|
||||
}
|
||||
|
||||
return { name: 'last7d', label: 'Last 7 days', range: DEFAULT_DATE_RANGE };
|
||||
}, [dateRange]);
|
||||
|
||||
// Resolve relative date range to ISO strings for the operationsStats query
|
||||
const resolvedPeriod = useMemo(() => {
|
||||
const from = parse(dateRange.from);
|
||||
const to = parse(dateRange.to);
|
||||
if (from && to) {
|
||||
return { from: formatISO(from), to: formatISO(to) };
|
||||
}
|
||||
const fallbackFrom = parse('now-7d')!;
|
||||
const fallbackTo = parse('now')!;
|
||||
return { from: formatISO(fallbackFrom), to: formatISO(fallbackTo) };
|
||||
}, [dateRange]);
|
||||
|
||||
// Fetch all operations and clients for the date range
|
||||
const [opsQuery] = useQuery({
|
||||
query: ManageFilters_OperationStatsQuery,
|
||||
variables: {
|
||||
selector: { organizationSlug, projectSlug, targetSlug },
|
||||
period: resolvedPeriod,
|
||||
},
|
||||
});
|
||||
|
||||
// Build merged operation items (all from stats + any saved hashes not in stats)
|
||||
const { allOperationItems, hashToNameMap } = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
const statsOps = opsQuery.data?.target?.operationsStats?.operations?.edges ?? [];
|
||||
|
||||
for (const edge of statsOps) {
|
||||
if (edge.node.operationHash) {
|
||||
map.set(edge.node.operationHash, edge.node.name);
|
||||
}
|
||||
}
|
||||
|
||||
const items: FilterItem[] = statsOps
|
||||
.filter(e => e.node.operationHash != null)
|
||||
.map(e => ({
|
||||
id: e.node.operationHash!,
|
||||
name: e.node.name,
|
||||
values: [],
|
||||
}));
|
||||
|
||||
// Add any saved hashes that aren't in the current stats (fallback: show hash as name)
|
||||
for (const hash of operationHashes) {
|
||||
if (!map.has(hash)) {
|
||||
items.push({ id: hash, name: hash, values: [], unavailable: true });
|
||||
}
|
||||
}
|
||||
|
||||
return { allOperationItems: items, hashToNameMap: map };
|
||||
}, [opsQuery.data, operationHashes]);
|
||||
|
||||
// Build merged client items (all from stats + any saved clients not in stats)
|
||||
const allClientItems = useMemo<FilterItem[]>(() => {
|
||||
const statsClients = opsQuery.data?.target?.operationsStats?.clients?.edges ?? [];
|
||||
const clientNameSet = new Set(statsClients.map(e => e.node.name));
|
||||
|
||||
const items: FilterItem[] = statsClients.map(e => ({
|
||||
name: e.node.name,
|
||||
values: e.node.versions.map(v => v.version),
|
||||
}));
|
||||
|
||||
// Add saved clients not in current stats, preserving their versions
|
||||
for (const cf of clientFilters) {
|
||||
if (!clientNameSet.has(cf.name)) {
|
||||
items.push({ name: cf.name, values: cf.versions ?? [], unavailable: true });
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [opsQuery.data, clientFilters]);
|
||||
|
||||
// While stats are loading, use saved data as fallback for items
|
||||
const operationItems = opsQuery.fetching
|
||||
? operationHashes.map(hash => ({ id: hash, name: hash, values: [] as string[] }))
|
||||
: allOperationItems;
|
||||
const clientItems = opsQuery.fetching
|
||||
? clientFilters.map(c => ({ name: c.name, values: c.versions ?? [] }))
|
||||
: allClientItems;
|
||||
|
||||
// Compute the "saved" selections (what's persisted in the database)
|
||||
const savedOperationSelections = useMemo<FilterSelection[]>(
|
||||
() =>
|
||||
operationHashes.map(hash => ({
|
||||
id: hash,
|
||||
name: hashToNameMap.get(hash) ?? hash,
|
||||
values: null,
|
||||
})),
|
||||
[operationHashes, hashToNameMap],
|
||||
);
|
||||
const savedClientSelections = useMemo<FilterSelection[]>(
|
||||
() =>
|
||||
clientFilters.map(c => ({
|
||||
name: c.name,
|
||||
values: c.versions?.length ? [...c.versions] : null,
|
||||
})),
|
||||
[clientFilters],
|
||||
);
|
||||
|
||||
// Mutable selections (initialized from saved data)
|
||||
const [operationSelections, setOperationSelections] =
|
||||
useState<FilterSelection[]>(savedOperationSelections);
|
||||
const [clientSelections, setClientSelections] =
|
||||
useState<FilterSelection[]>(savedClientSelections);
|
||||
const [showOperationFilter, setShowOperationFilter] = useState(operationHashes.length > 0);
|
||||
const [showClientFilter, setShowClientFilter] = useState(clientFilters.length > 0);
|
||||
|
||||
// Sync state when saved filter data changes (e.g. after mutation updates cache)
|
||||
const filterDataKey = JSON.stringify(filter.filters);
|
||||
useEffect(() => {
|
||||
setOperationSelections(savedOperationSelections);
|
||||
setClientSelections(savedClientSelections);
|
||||
setShowOperationFilter(operationHashes.length > 0);
|
||||
setShowClientFilter(clientFilters.length > 0);
|
||||
setDateRange(savedDateRange);
|
||||
}, [filterDataKey]);
|
||||
|
||||
// Detect changes from saved state (compare by identifiers, not display names)
|
||||
const hasChanges = useMemo(() => {
|
||||
if (showOperationFilter !== operationHashes.length > 0) return true;
|
||||
if (showClientFilter !== clientFilters.length > 0) return true;
|
||||
if (dateRange.from !== savedDateRange.from || dateRange.to !== savedDateRange.to) return true;
|
||||
|
||||
const normalizeOps = (sels: FilterSelection[]) =>
|
||||
sels
|
||||
.map(s => s.id ?? s.name)
|
||||
.sort()
|
||||
.join('\0');
|
||||
if (normalizeOps(operationSelections) !== normalizeOps(savedOperationSelections)) return true;
|
||||
|
||||
const normalizeClients = (sels: FilterSelection[]) =>
|
||||
JSON.stringify(
|
||||
sels
|
||||
.map(s => ({ name: s.name, values: s.values }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
);
|
||||
if (normalizeClients(clientSelections) !== normalizeClients(savedClientSelections)) return true;
|
||||
|
||||
return false;
|
||||
}, [
|
||||
operationSelections,
|
||||
clientSelections,
|
||||
showOperationFilter,
|
||||
showClientFilter,
|
||||
dateRange,
|
||||
savedOperationSelections,
|
||||
savedClientSelections,
|
||||
operationHashes,
|
||||
clientFilters,
|
||||
savedDateRange,
|
||||
]);
|
||||
|
||||
// Cancel → reset to saved state
|
||||
const handleCancel = useCallback(() => {
|
||||
setOperationSelections(savedOperationSelections);
|
||||
setClientSelections(savedClientSelections);
|
||||
setShowOperationFilter(operationHashes.length > 0);
|
||||
setShowClientFilter(clientFilters.length > 0);
|
||||
setDateRange(savedDateRange);
|
||||
}, [
|
||||
savedOperationSelections,
|
||||
savedClientSelections,
|
||||
operationHashes,
|
||||
clientFilters,
|
||||
savedDateRange,
|
||||
]);
|
||||
|
||||
// Update mutation
|
||||
const [updateResult, updateSavedFilter] = useMutation(ManageFilters_UpdateSavedFilterMutation);
|
||||
const { toast } = useToast();
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
// Extract hashes from selections (id is the hash, fallback to name for backwards compat)
|
||||
const newOperationHashes = showOperationFilter
|
||||
? operationSelections.map(s => s.id ?? s.name)
|
||||
: [];
|
||||
|
||||
const newClientFilters = showClientFilter
|
||||
? clientSelections.map(s => ({
|
||||
name: s.name,
|
||||
versions:
|
||||
s.values === null ? (clientItems.find(i => i.name === s.name)?.values ?? []) : s.values,
|
||||
}))
|
||||
: [];
|
||||
|
||||
await updateSavedFilter({
|
||||
input: {
|
||||
id: filter.id,
|
||||
target: {
|
||||
bySelector: { organizationSlug, projectSlug, targetSlug },
|
||||
},
|
||||
insightsFilter: {
|
||||
operationHashes: newOperationHashes,
|
||||
clientFilters: newClientFilters,
|
||||
dateRange: { from: dateRange.from, to: dateRange.to },
|
||||
},
|
||||
},
|
||||
}).then(result => {
|
||||
if (result.error || result.data?.updateSavedFilter.error) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Error',
|
||||
description: result.error?.message || result.data?.updateSavedFilter.error?.message,
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
variant: 'success',
|
||||
title: 'Filter updated',
|
||||
description: 'The saved filter has been updated.',
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [
|
||||
showOperationFilter,
|
||||
showClientFilter,
|
||||
operationSelections,
|
||||
clientSelections,
|
||||
clientItems,
|
||||
dateRange,
|
||||
filter.id,
|
||||
organizationSlug,
|
||||
projectSlug,
|
||||
targetSlug,
|
||||
updateSavedFilter,
|
||||
toast,
|
||||
]);
|
||||
|
||||
const loading = opsQuery.fetching;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<DateRangePicker
|
||||
trigger={
|
||||
<TriggerButton
|
||||
label={selectedPreset.label}
|
||||
variant="default"
|
||||
rightIcon={{ icon: ChevronDown, withSeparator: true }}
|
||||
/>
|
||||
}
|
||||
selectedRange={dateRange}
|
||||
onUpdate={({ preset }) => setDateRange(preset.range)}
|
||||
startDate={startDate}
|
||||
validUnits={['y', 'M', 'w', 'd', 'h']}
|
||||
align="start"
|
||||
/>
|
||||
{showOperationFilter && operationItems.length > 0 && (
|
||||
<FilterDropdown
|
||||
label="Operation"
|
||||
items={operationItems}
|
||||
selectedItems={operationSelections}
|
||||
onChange={setOperationSelections}
|
||||
onRemove={() => {
|
||||
setShowOperationFilter(false);
|
||||
setOperationSelections([]);
|
||||
}}
|
||||
disabled={loading}
|
||||
/>
|
||||
)}
|
||||
{showClientFilter && clientItems.length > 0 && (
|
||||
<FilterDropdown
|
||||
label="Client"
|
||||
items={clientItems}
|
||||
selectedItems={clientSelections}
|
||||
onChange={setClientSelections}
|
||||
onRemove={() => {
|
||||
setShowClientFilter(false);
|
||||
setClientSelections([]);
|
||||
}}
|
||||
valuesLabel="versions"
|
||||
disabled={loading}
|
||||
/>
|
||||
)}
|
||||
{loading && <Spinner />}
|
||||
</div>
|
||||
{filter.viewerCanUpdate && (
|
||||
<div className="mt-3 flex gap-2">
|
||||
<Button
|
||||
variant={hasChanges ? 'primary' : 'default'}
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={updateResult.fetching || !hasChanges}
|
||||
>
|
||||
Save changes
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={handleCancel}
|
||||
disabled={updateResult.fetching || !hasChanges}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ManageFiltersContent(props: {
|
||||
organizationSlug: string;
|
||||
projectSlug: string;
|
||||
targetSlug: string;
|
||||
}) {
|
||||
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set());
|
||||
const [query] = useQuery({
|
||||
query: ManageFilters_SavedFiltersQuery,
|
||||
variables: {
|
||||
organizationSlug: props.organizationSlug,
|
||||
selector: {
|
||||
organizationSlug: props.organizationSlug,
|
||||
projectSlug: props.projectSlug,
|
||||
targetSlug: props.targetSlug,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const dataRetentionInDays = query.data?.organization?.usageRetentionInDays ?? 30;
|
||||
|
||||
const toggleRow = useCallback((id: string) => {
|
||||
setExpandedRows(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const edges = query.data?.target?.savedFilters.edges ?? [];
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const filters = edges.map(e => e.node);
|
||||
return {
|
||||
total: filters.length,
|
||||
shared: filters.filter(f => f.visibility === SavedFilterVisibilityType.Shared).length,
|
||||
totalViews: filters.reduce((sum, f) => sum + f.viewsCount, 0),
|
||||
};
|
||||
}, [edges]);
|
||||
|
||||
if (query.error) {
|
||||
return (
|
||||
<QueryError
|
||||
organizationSlug={props.organizationSlug}
|
||||
error={query.error}
|
||||
showLogoutButton={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!query.data?.target) {
|
||||
return (
|
||||
<div className="flex h-fit flex-1 items-center justify-center py-28">
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (edges.length === 0) {
|
||||
return (
|
||||
<div className="py-8">
|
||||
<EmptyList
|
||||
title="No saved filters"
|
||||
description="You haven't created any saved filters yet. Create filters from the Insights page to save and share them."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mt-6 grid grid-cols-3 gap-4">
|
||||
<StatCard label="Total Filters" value={stats.total} />
|
||||
<StatCard label="Shared Filters" value={stats.shared} />
|
||||
<StatCard label="Total Views" value={stats.totalViews} />
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-8" />
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Views</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead>Modified</TableHead>
|
||||
<TableHead>Visibility</TableHead>
|
||||
<TableHead className="w-12" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{edges.map(edge => (
|
||||
<SavedFilterRow
|
||||
key={edge.node.id}
|
||||
filter={edge.node}
|
||||
expanded={expandedRows.has(edge.node.id)}
|
||||
onToggleExpand={() => toggleRow(edge.node.id)}
|
||||
organizationSlug={props.organizationSlug}
|
||||
projectSlug={props.projectSlug}
|
||||
targetSlug={props.targetSlug}
|
||||
dataRetentionInDays={dataRetentionInDays}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ label, value }: { label: string; value: number }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-neutral-10 text-sm font-medium">{label}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{value.toLocaleString()}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function TargetInsightsManageFiltersPage(props: {
|
||||
organizationSlug: string;
|
||||
projectSlug: string;
|
||||
targetSlug: string;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<>
|
||||
<Meta title="Manage saved filters" />
|
||||
<TargetLayout
|
||||
organizationSlug={props.organizationSlug}
|
||||
projectSlug={props.projectSlug}
|
||||
targetSlug={props.targetSlug}
|
||||
page={Page.Insights}
|
||||
>
|
||||
<div className="py-6">
|
||||
<Link
|
||||
to="/$organizationSlug/$projectSlug/$targetSlug/insights"
|
||||
params={{
|
||||
organizationSlug: props.organizationSlug,
|
||||
projectSlug: props.projectSlug,
|
||||
targetSlug: props.targetSlug,
|
||||
}}
|
||||
search={{}}
|
||||
className="text-neutral-10 hover:text-neutral-12 mb-4 inline-flex items-center gap-1 text-sm transition-colors"
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
Back to Insights
|
||||
</Link>
|
||||
<Title>Manage saved filters</Title>
|
||||
<Subtitle>View and manage your saved filter views</Subtitle>
|
||||
</div>
|
||||
<ManageFiltersContent {...props} />
|
||||
</TargetLayout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -4,7 +4,6 @@ import { useQuery } from 'urql';
|
|||
import { Section } from '@/components/common';
|
||||
import { GraphQLHighlight } from '@/components/common/GraphQLSDLBlock';
|
||||
import { Page, TargetLayout } from '@/components/layouts/target';
|
||||
import { ClientsFilterTrigger } from '@/components/target/insights/Filters';
|
||||
import { OperationsStats } from '@/components/target/insights/Stats';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
|
@ -16,7 +15,6 @@ import { Subtitle, Title } from '@/components/ui/page';
|
|||
import { QueryError } from '@/components/ui/query-error';
|
||||
import { FragmentType, graphql, useFragment } from '@/gql';
|
||||
import { useDateRangeController } from '@/lib/hooks/use-date-range-controller';
|
||||
import { useSearchParamsFilter } from '@/lib/hooks/use-search-params-filters';
|
||||
|
||||
const GraphQLOperationBody_OperationFragment = graphql(`
|
||||
fragment GraphQLOperationBody_OperationFragment on Operation {
|
||||
|
|
@ -67,8 +65,7 @@ function OperationView({
|
|||
dataRetentionInDays,
|
||||
defaultPreset: presetLast1Day,
|
||||
});
|
||||
const [selectedClients, setSelectedClients] = useSearchParamsFilter<string[]>('clients', []);
|
||||
const operationsList = useMemo(() => [operationHash], [operationHash]);
|
||||
const operationFilter = useMemo(() => ({ operationIds: [operationHash] }), [operationHash]);
|
||||
|
||||
const [result] = useQuery({
|
||||
query: Operation_View_OperationBodyQuery,
|
||||
|
|
@ -95,15 +92,6 @@ function OperationView({
|
|||
</div>
|
||||
{!result.fetching && isNotNoQueryOrMutation === false && (
|
||||
<div className="flex justify-end gap-x-2">
|
||||
<ClientsFilterTrigger
|
||||
period={dateRangeController.resolvedRange}
|
||||
selected={selectedClients}
|
||||
selectedOperationIds={[operationHash]}
|
||||
onFilter={setSelectedClients}
|
||||
organizationSlug={organizationSlug}
|
||||
projectSlug={projectSlug}
|
||||
targetSlug={targetSlug}
|
||||
/>
|
||||
<DateRangePicker
|
||||
validUnits={['y', 'M', 'w', 'd', 'h']}
|
||||
selectedRange={dateRangeController.selectedPreset.range}
|
||||
|
|
@ -124,8 +112,7 @@ function OperationView({
|
|||
targetSlug={targetSlug}
|
||||
period={dateRangeController.resolvedRange}
|
||||
dateRangeText={dateRangeController.selectedPreset.label}
|
||||
operationsFilter={operationsList}
|
||||
clientNamesFilter={selectedClients}
|
||||
filter={operationFilter}
|
||||
mode="operation-page"
|
||||
resolution={dateRangeController.resolution}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,22 +1,117 @@
|
|||
import { ReactElement } from 'react';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import { useQuery } from 'urql';
|
||||
import { ReactElement, useCallback, useEffect, useMemo } from 'react';
|
||||
import { ChevronDown, RefreshCw } from 'lucide-react';
|
||||
import { useMutation, useQuery } from 'urql';
|
||||
import { z } from 'zod';
|
||||
import { FilterDropdown } from '@/components/base/filter-dropdown/filter-dropdown';
|
||||
import type { FilterItem, FilterSelection } from '@/components/base/filter-dropdown/types';
|
||||
import type { SavedFilterView } from '@/components/base/insights-filters';
|
||||
import { InsightsFilters } from '@/components/base/insights-filters';
|
||||
import { TriggerButton } from '@/components/base/trigger-button';
|
||||
import { Page, TargetLayout } from '@/components/layouts/target';
|
||||
import {
|
||||
ClientsFilterTrigger,
|
||||
OperationsFilterTrigger,
|
||||
} from '@/components/target/insights/Filters';
|
||||
import { OperationsList } from '@/components/target/insights/List';
|
||||
import { SaveFilterButton } from '@/components/target/insights/save-filter-button';
|
||||
import { OperationsStats } from '@/components/target/insights/Stats';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { DateRangePicker, presetLast7Days } from '@/components/ui/date-range-picker';
|
||||
import { EmptyList } from '@/components/ui/empty-list';
|
||||
import { Meta } from '@/components/ui/meta';
|
||||
import { Subtitle, Title } from '@/components/ui/page';
|
||||
import { QueryError } from '@/components/ui/query-error';
|
||||
import { graphql } from '@/gql';
|
||||
import { OperationStatsFilterInput, SavedFilterVisibilityType } from '@/gql/graphql';
|
||||
import { useDateRangeController } from '@/lib/hooks/use-date-range-controller';
|
||||
import { useSearchParamsFilter } from '@/lib/hooks/use-search-params-filters';
|
||||
import { useNavigate, useSearch } from '@tanstack/react-router';
|
||||
|
||||
const InsightsClientFilter = z.object({
|
||||
name: z.string(),
|
||||
versions: z.array(z.string()).nullable().default(null),
|
||||
});
|
||||
|
||||
export const InsightsFilterSearch = z.object({
|
||||
operations: z.array(z.string()).optional(),
|
||||
clients: z.array(InsightsClientFilter).optional(),
|
||||
from: z.string().optional(),
|
||||
to: z.string().optional(),
|
||||
viewId: z.string().optional(),
|
||||
});
|
||||
|
||||
type InsightsFilterState = z.infer<typeof InsightsFilterSearch>;
|
||||
|
||||
function buildGraphQLFilter(state: InsightsFilterState): OperationStatsFilterInput {
|
||||
return {
|
||||
operationIds: state.operations?.length ? state.operations : undefined,
|
||||
clientVersionFilters: state.clients?.length
|
||||
? state.clients.map(c => ({
|
||||
clientName: c.name,
|
||||
versions: c.versions,
|
||||
}))
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const InsightsFilterPicker_Query = graphql(`
|
||||
query InsightsFilterPicker($selector: TargetSelectorInput!, $period: DateRangeInput!) {
|
||||
target(reference: { bySelector: $selector }) {
|
||||
id
|
||||
viewerCanCreateSavedFilter
|
||||
viewerCanShareSavedFilter
|
||||
operationsStats(period: $period) {
|
||||
operations {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
name
|
||||
operationHash
|
||||
}
|
||||
}
|
||||
}
|
||||
clients {
|
||||
edges {
|
||||
node {
|
||||
name
|
||||
versions {
|
||||
version
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
savedFilters(first: 50) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
name
|
||||
visibility
|
||||
viewerCanUpdate
|
||||
filters {
|
||||
operationHashes
|
||||
clientFilters {
|
||||
name
|
||||
versions
|
||||
}
|
||||
dateRange {
|
||||
from
|
||||
to
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const InsightsTrackView_Mutation = graphql(`
|
||||
mutation InsightsTrackView($input: TrackSavedFilterViewInput!) {
|
||||
trackSavedFilterView(input: $input) {
|
||||
ok {
|
||||
savedFilter {
|
||||
id
|
||||
viewsCount
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
function OperationsView({
|
||||
organizationSlug,
|
||||
|
|
@ -29,52 +124,336 @@ function OperationsView({
|
|||
targetSlug: string;
|
||||
dataRetentionInDays: number;
|
||||
}): ReactElement {
|
||||
const [selectedOperations, setSelectedOperations] = useSearchParamsFilter<string[]>(
|
||||
'operations',
|
||||
[],
|
||||
);
|
||||
const [selectedClients, setSelectedClients] = useSearchParamsFilter<string[]>('clients', []);
|
||||
const search = useSearch({
|
||||
from: '/authenticated/$organizationSlug/$projectSlug/$targetSlug/insights',
|
||||
});
|
||||
const navigate = useNavigate();
|
||||
const dateRangeController = useDateRangeController({
|
||||
dataRetentionInDays,
|
||||
defaultPreset: presetLast7Days,
|
||||
});
|
||||
|
||||
// Populate URL with the default date range on initial load so the URL always reflects the active range.
|
||||
// Skipped when from/to are already present (e.g. shared link or saved filter).
|
||||
useEffect(() => {
|
||||
if (search.from === undefined && search.to === undefined) {
|
||||
void navigate({
|
||||
search: prev => ({
|
||||
...prev,
|
||||
from: presetLast7Days.range.from,
|
||||
to: presetLast7Days.range.to,
|
||||
}),
|
||||
replace: true,
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const [pickerQuery, reexecutePickerQuery] = useQuery({
|
||||
query: InsightsFilterPicker_Query,
|
||||
variables: {
|
||||
selector: { organizationSlug, projectSlug, targetSlug },
|
||||
period: dateRangeController.resolvedRange,
|
||||
},
|
||||
});
|
||||
|
||||
const operationFilterItems: FilterItem[] = useMemo(() => {
|
||||
const edges = pickerQuery.data?.target?.operationsStats?.operations?.edges ?? [];
|
||||
return edges
|
||||
.filter(e => e.node.operationHash != null)
|
||||
.map(e => ({
|
||||
id: e.node.operationHash!,
|
||||
name: e.node.name,
|
||||
values: [],
|
||||
}));
|
||||
}, [pickerQuery.data]);
|
||||
|
||||
const clientFilterItems: FilterItem[] = useMemo(() => {
|
||||
const edges = pickerQuery.data?.target?.operationsStats?.clients?.edges ?? [];
|
||||
return edges.map(e => ({
|
||||
name: e.node.name,
|
||||
values: e.node.versions.map(v => v.version),
|
||||
}));
|
||||
}, [pickerQuery.data]);
|
||||
|
||||
// Build a hash→name map for converting search state hashes back to display names
|
||||
const hashToNameMap = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const item of operationFilterItems) {
|
||||
if (item.id) {
|
||||
map.set(item.id, item.name);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [operationFilterItems]);
|
||||
|
||||
const operationFilterSelections: FilterSelection[] = useMemo(
|
||||
() =>
|
||||
(search.operations ?? []).map(hash => ({
|
||||
id: hash,
|
||||
name: hashToNameMap.get(hash) ?? hash,
|
||||
values: null,
|
||||
})),
|
||||
[search.operations, hashToNameMap],
|
||||
);
|
||||
|
||||
const clientFilterSelections: FilterSelection[] = useMemo(
|
||||
() =>
|
||||
(search.clients ?? []).map(c => ({
|
||||
name: c.name,
|
||||
values: c.versions,
|
||||
})),
|
||||
[search.clients],
|
||||
);
|
||||
|
||||
const { privateSavedFilterViews, sharedSavedFilterViews } = useMemo(() => {
|
||||
const edges = pickerQuery.data?.target?.savedFilters?.edges ?? [];
|
||||
const privateSavedFilterViews: SavedFilterView[] = [];
|
||||
const sharedSavedFilterViews: SavedFilterView[] = [];
|
||||
|
||||
for (const edge of edges) {
|
||||
const node = edge.node;
|
||||
const view: SavedFilterView = {
|
||||
id: node.id,
|
||||
name: node.name,
|
||||
viewerCanUpdate: node.viewerCanUpdate,
|
||||
filters: {
|
||||
operationHashes: node.filters.operationHashes,
|
||||
clientFilters: node.filters.clientFilters.map(c => ({
|
||||
name: c.name,
|
||||
versions: c.versions ?? null,
|
||||
})),
|
||||
dateRange: node.filters.dateRange ?? null,
|
||||
},
|
||||
};
|
||||
|
||||
if (node.visibility === SavedFilterVisibilityType.Private) {
|
||||
privateSavedFilterViews.push(view);
|
||||
} else {
|
||||
sharedSavedFilterViews.push(view);
|
||||
}
|
||||
}
|
||||
|
||||
return { privateSavedFilterViews, sharedSavedFilterViews };
|
||||
}, [pickerQuery.data]);
|
||||
|
||||
const [, trackView] = useMutation(InsightsTrackView_Mutation);
|
||||
|
||||
const handleApplySavedFilter = useCallback(
|
||||
(view: SavedFilterView) => {
|
||||
void navigate({
|
||||
search: prev => ({
|
||||
...prev,
|
||||
operations:
|
||||
view.filters.operationHashes.length > 0 ? view.filters.operationHashes : undefined,
|
||||
clients:
|
||||
view.filters.clientFilters.length > 0
|
||||
? view.filters.clientFilters.map(c => ({
|
||||
name: c.name,
|
||||
versions: c.versions,
|
||||
}))
|
||||
: undefined,
|
||||
from: view.filters.dateRange?.from,
|
||||
to: view.filters.dateRange?.to,
|
||||
viewId: view.id,
|
||||
}),
|
||||
});
|
||||
},
|
||||
[navigate, organizationSlug, projectSlug, targetSlug],
|
||||
);
|
||||
|
||||
const viewerCanCreate = pickerQuery.data?.target?.viewerCanCreateSavedFilter ?? false;
|
||||
const viewerCanShare = pickerQuery.data?.target?.viewerCanShareSavedFilter ?? false;
|
||||
|
||||
const activeView = useMemo(() => {
|
||||
if (!search.viewId) return null;
|
||||
const allViews = [...privateSavedFilterViews, ...sharedSavedFilterViews];
|
||||
return allViews.find(v => v.id === search.viewId) ?? null;
|
||||
}, [search.viewId, privateSavedFilterViews, sharedSavedFilterViews]);
|
||||
|
||||
useEffect(() => {
|
||||
if (search.viewId) {
|
||||
void trackView({
|
||||
input: {
|
||||
target: { bySelector: { organizationSlug, projectSlug, targetSlug } },
|
||||
id: search.viewId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [search.viewId]);
|
||||
|
||||
const hasActiveFilters = useMemo(
|
||||
() =>
|
||||
(search.operations && search.operations.length > 0) ||
|
||||
(search.clients && search.clients.length > 0) ||
|
||||
(search.from !== undefined && search.from !== presetLast7Days.range.from) ||
|
||||
(search.to !== undefined && search.to !== presetLast7Days.range.to),
|
||||
[search.operations, search.clients, search.from, search.to],
|
||||
);
|
||||
|
||||
const filter = useMemo(() => buildGraphQLFilter(search), [search]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-row items-center justify-between py-6">
|
||||
<div className="py-6">
|
||||
<div>
|
||||
<Title>Insights</Title>
|
||||
<Subtitle>Observe GraphQL requests and see how the API is consumed.</Subtitle>
|
||||
</div>
|
||||
<div className="flex justify-end gap-x-4">
|
||||
<OperationsFilterTrigger
|
||||
organizationSlug={organizationSlug}
|
||||
projectSlug={projectSlug}
|
||||
targetSlug={targetSlug}
|
||||
period={dateRangeController.resolvedRange}
|
||||
selected={selectedOperations}
|
||||
onFilter={setSelectedOperations}
|
||||
clientNames={selectedClients}
|
||||
/>
|
||||
<ClientsFilterTrigger
|
||||
organizationSlug={organizationSlug}
|
||||
projectSlug={projectSlug}
|
||||
targetSlug={targetSlug}
|
||||
period={dateRangeController.resolvedRange}
|
||||
selected={selectedClients}
|
||||
selectedOperationIds={selectedOperations}
|
||||
onFilter={setSelectedClients}
|
||||
/>
|
||||
<DateRangePicker
|
||||
validUnits={['y', 'M', 'w', 'd', 'h']}
|
||||
selectedRange={dateRangeController.selectedPreset.range}
|
||||
startDate={dateRangeController.startDate}
|
||||
align="end"
|
||||
onUpdate={args => dateRangeController.setSelectedPreset(args.preset)}
|
||||
/>
|
||||
<Button variant="outline" onClick={() => dateRangeController.refreshResolvedRange()}>
|
||||
<RefreshCw className="size-4" />
|
||||
</Button>
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<InsightsFilters
|
||||
operationFilterItems={operationFilterItems}
|
||||
operationFilterSelections={operationFilterSelections}
|
||||
clientFilterItems={clientFilterItems}
|
||||
clientFilterSelections={clientFilterSelections}
|
||||
privateSavedFilterViews={privateSavedFilterViews}
|
||||
sharedSavedFilterViews={sharedSavedFilterViews}
|
||||
onApplySavedFilters={handleApplySavedFilter}
|
||||
activeViewId={search.viewId}
|
||||
onClearActiveView={() => {
|
||||
void navigate({
|
||||
search: prev => ({
|
||||
...prev,
|
||||
viewId: undefined,
|
||||
operations: undefined,
|
||||
clients: undefined,
|
||||
from: presetLast7Days.range.from,
|
||||
to: presetLast7Days.range.to,
|
||||
}),
|
||||
});
|
||||
}}
|
||||
onManageSavedFilters={() => {
|
||||
void navigate({
|
||||
to: '/$organizationSlug/$projectSlug/$targetSlug/insights/manage-filters',
|
||||
params: { organizationSlug, projectSlug, targetSlug },
|
||||
});
|
||||
}}
|
||||
setOperationSelections={selections => {
|
||||
void navigate({
|
||||
search: prev => ({
|
||||
...prev,
|
||||
operations:
|
||||
selections.length > 0 ? selections.map(s => s.id ?? s.name) : undefined,
|
||||
}),
|
||||
});
|
||||
}}
|
||||
setClientSelections={selections => {
|
||||
void navigate({
|
||||
search: prev => ({
|
||||
...prev,
|
||||
clients:
|
||||
selections.length > 0
|
||||
? selections.map(s => ({
|
||||
name: s.name,
|
||||
versions: s.values,
|
||||
}))
|
||||
: undefined,
|
||||
}),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<DateRangePicker
|
||||
trigger={
|
||||
<TriggerButton
|
||||
label={dateRangeController.selectedPreset.label}
|
||||
variant="default"
|
||||
rightIcon={{ icon: ChevronDown, withSeparator: true }}
|
||||
/>
|
||||
}
|
||||
selectedRange={dateRangeController.selectedPreset.range}
|
||||
onUpdate={args => dateRangeController.setSelectedPreset(args.preset)}
|
||||
startDate={dateRangeController.startDate}
|
||||
validUnits={['y', 'M', 'w', 'd', 'h']}
|
||||
align="start"
|
||||
/>
|
||||
{operationFilterSelections.length > 0 && (
|
||||
<FilterDropdown
|
||||
label="Operation"
|
||||
items={operationFilterItems}
|
||||
selectedItems={operationFilterSelections}
|
||||
onChange={selections => {
|
||||
void navigate({
|
||||
search: prev => ({
|
||||
...prev,
|
||||
operations:
|
||||
selections.length > 0 ? selections.map(s => s.id ?? s.name) : undefined,
|
||||
}),
|
||||
});
|
||||
}}
|
||||
onRemove={() => {
|
||||
void navigate({
|
||||
search: prev => ({ ...prev, operations: undefined }),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{clientFilterSelections.length > 0 && (
|
||||
<FilterDropdown
|
||||
label="Client"
|
||||
items={clientFilterItems}
|
||||
selectedItems={clientFilterSelections}
|
||||
valuesLabel="versions"
|
||||
onChange={selections => {
|
||||
void navigate({
|
||||
search: prev => ({
|
||||
...prev,
|
||||
clients:
|
||||
selections.length > 0
|
||||
? selections.map(s => ({
|
||||
name: s.name,
|
||||
versions: s.values,
|
||||
}))
|
||||
: undefined,
|
||||
}),
|
||||
});
|
||||
}}
|
||||
onRemove={() => {
|
||||
void navigate({
|
||||
search: prev => ({ ...prev, clients: undefined }),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{hasActiveFilters && (
|
||||
<SaveFilterButton
|
||||
activeView={activeView}
|
||||
viewerCanCreate={viewerCanCreate}
|
||||
viewerCanShare={viewerCanShare}
|
||||
currentFilters={{
|
||||
operations: search.operations ?? [],
|
||||
clients: (search.clients ?? []).map(c => ({
|
||||
name: c.name,
|
||||
versions: c.versions,
|
||||
})),
|
||||
dateRange: {
|
||||
from: search.from ?? dateRangeController.selectedPreset.range.from,
|
||||
to: search.to ?? dateRangeController.selectedPreset.range.to,
|
||||
},
|
||||
}}
|
||||
organizationSlug={organizationSlug}
|
||||
projectSlug={projectSlug}
|
||||
targetSlug={targetSlug}
|
||||
onSaved={viewId => {
|
||||
void navigate({
|
||||
search: prev => ({ ...prev, viewId }),
|
||||
});
|
||||
reexecutePickerQuery({ requestPolicy: 'network-only' });
|
||||
}}
|
||||
onUpdated={() => {
|
||||
// Refetch to get updated saved filter data
|
||||
reexecutePickerQuery({ requestPolicy: 'network-only' });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<TriggerButton
|
||||
layout="iconOnly"
|
||||
icon={RefreshCw}
|
||||
aria-label="Refresh"
|
||||
onClick={() => dateRangeController.refreshResolvedRange()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<OperationsStats
|
||||
|
|
@ -82,8 +461,7 @@ function OperationsView({
|
|||
projectSlug={projectSlug}
|
||||
targetSlug={targetSlug}
|
||||
period={dateRangeController.resolvedRange}
|
||||
operationsFilter={selectedOperations}
|
||||
clientNamesFilter={selectedClients}
|
||||
filter={filter}
|
||||
dateRangeText={dateRangeController.selectedPreset.label}
|
||||
mode="operation-list"
|
||||
resolution={dateRangeController.resolution}
|
||||
|
|
@ -94,8 +472,7 @@ function OperationsView({
|
|||
organizationSlug={organizationSlug}
|
||||
projectSlug={projectSlug}
|
||||
targetSlug={targetSlug}
|
||||
operationsFilter={selectedOperations}
|
||||
clientNamesFilter={selectedClients}
|
||||
filter={filter}
|
||||
selectedPeriod={dateRangeController.selectedPreset.range}
|
||||
/>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -74,9 +74,10 @@ import { TargetExplorerTypePage } from './pages/target-explorer-type';
|
|||
import { TargetExplorerUnusedPage } from './pages/target-explorer-unused';
|
||||
import { TargetHistoryPage } from './pages/target-history';
|
||||
import { TargetHistoryVersionPage } from './pages/target-history-version';
|
||||
import { TargetInsightsPage } from './pages/target-insights';
|
||||
import { InsightsFilterSearch, TargetInsightsPage } from './pages/target-insights';
|
||||
import { TargetInsightsClientPage } from './pages/target-insights-client';
|
||||
import { TargetInsightsCoordinatePage } from './pages/target-insights-coordinate';
|
||||
import { TargetInsightsManageFiltersPage } from './pages/target-insights-manage-filters';
|
||||
import { TargetInsightsOperationPage } from './pages/target-insights-operation';
|
||||
import { TargetLaboratoryPage } from './pages/target-laboratory';
|
||||
import { TargetLaboratoryPage as TargetLaboratoryPageNew } from './pages/target-laboratory-new';
|
||||
|
|
@ -692,9 +693,10 @@ const targetAppVersionRoute = createRoute({
|
|||
},
|
||||
});
|
||||
|
||||
const targetInsightsRoute = createRoute({
|
||||
export const targetInsightsRoute = createRoute({
|
||||
getParentRoute: () => targetRoute,
|
||||
path: 'insights',
|
||||
validateSearch: InsightsFilterSearch.parse,
|
||||
component: function TargetInsightsRoute() {
|
||||
const { organizationSlug, projectSlug, targetSlug } = targetInsightsRoute.useParams();
|
||||
return (
|
||||
|
|
@ -707,6 +709,22 @@ const targetInsightsRoute = createRoute({
|
|||
},
|
||||
});
|
||||
|
||||
const targetInsightsManageFiltersRoute = createRoute({
|
||||
getParentRoute: () => targetRoute,
|
||||
path: 'insights/manage-filters',
|
||||
component: function TargetInsightsManageFiltersRoute() {
|
||||
const { organizationSlug, projectSlug, targetSlug } =
|
||||
targetInsightsManageFiltersRoute.useParams();
|
||||
return (
|
||||
<TargetInsightsManageFiltersPage
|
||||
organizationSlug={organizationSlug}
|
||||
projectSlug={projectSlug}
|
||||
targetSlug={targetSlug}
|
||||
/>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const TargetTracesRouteSearch = z.object({
|
||||
filter: TargetTracesFilterState.optional(),
|
||||
sort: TargetTracesSort.shape.optional(),
|
||||
|
|
@ -1107,6 +1125,7 @@ const routeTree = root.addChildren([
|
|||
targetLaboratoryRoute,
|
||||
targetHistoryRoute.addChildren([targetHistoryVersionRoute]),
|
||||
targetInsightsRoute,
|
||||
targetInsightsManageFiltersRoute,
|
||||
targetTraceRoute,
|
||||
targetTracesRoute,
|
||||
targetInsightsCoordinateRoute,
|
||||
|
|
|
|||
161
pnpm-lock.yaml
161
pnpm-lock.yaml
|
|
@ -1692,6 +1692,9 @@ importers:
|
|||
|
||||
packages/web/app:
|
||||
devDependencies:
|
||||
'@base-ui/react':
|
||||
specifier: ^1.1.0
|
||||
version: 1.1.0(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@date-fns/utc':
|
||||
specifier: 2.1.1
|
||||
version: 2.1.1
|
||||
|
|
@ -1866,6 +1869,9 @@ importers:
|
|||
'@tanstack/react-table':
|
||||
specifier: 8.20.6
|
||||
version: 8.20.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@tanstack/react-virtual':
|
||||
specifier: ^3.13.18
|
||||
version: 3.13.18(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@tanstack/router-devtools':
|
||||
specifier: 1.34.9
|
||||
version: 1.34.9(@tanstack/react-router@1.34.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(csstype@3.1.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
|
|
@ -1905,9 +1911,6 @@ importers:
|
|||
'@types/react-virtualized-auto-sizer':
|
||||
specifier: 1.0.4
|
||||
version: 1.0.4
|
||||
'@types/react-window':
|
||||
specifier: 1.8.8
|
||||
version: 1.8.8
|
||||
'@urql/core':
|
||||
specifier: 5.0.3
|
||||
version: 5.0.3(graphql@16.9.0)
|
||||
|
|
@ -2058,9 +2061,6 @@ importers:
|
|||
react-virtuoso:
|
||||
specifier: 4.12.3
|
||||
version: 4.12.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
react-window:
|
||||
specifier: 1.8.11
|
||||
version: 1.8.11(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
recharts:
|
||||
specifier: 2.15.1
|
||||
version: 2.15.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
|
|
@ -2159,7 +2159,7 @@ importers:
|
|||
version: 19.2.4
|
||||
react-avatar:
|
||||
specifier: 5.0.3
|
||||
version: 5.0.3(@babel/runtime@7.26.10)(core-js-pure@3.37.1)(prop-types@15.8.1)(react@19.2.4)
|
||||
version: 5.0.3(@babel/runtime@7.28.6)(core-js-pure@3.37.1)(prop-types@15.8.1)(react@19.2.4)
|
||||
react-countup:
|
||||
specifier: 6.5.3
|
||||
version: 6.5.3(react@19.2.4)
|
||||
|
|
@ -3240,6 +3240,10 @@ packages:
|
|||
resolution: {integrity: sha512-2WJMeRQPHKSPemqk/awGrAiuFfzBmOIPXKizAsVhWH9YJqLZ0H+HS4c8loHGgW6utJ3E/ejXQUsiGaQy2NZ9Fw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/runtime@7.28.6':
|
||||
resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/template@7.26.9':
|
||||
resolution: {integrity: sha512-qyRplbeIpNZhmzOysF/wFMuP9sctmh2cFzRAZOn1YapxBsE1i9bJIY586R/WBLfLcmcBlM8ROBiQURnnNy+zfA==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
|
@ -3263,6 +3267,27 @@ packages:
|
|||
'@balena/dockerignore@1.0.2':
|
||||
resolution: {integrity: sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==}
|
||||
|
||||
'@base-ui/react@1.1.0':
|
||||
resolution: {integrity: sha512-ikcJRNj1mOiF2HZ5jQHrXoVoHcNHdBU5ejJljcBl+VTLoYXR6FidjTN86GjO6hyshi6TZFuNvv0dEOgaOFv6Lw==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
peerDependencies:
|
||||
'@types/react': ^17 || ^18 || ^19
|
||||
react: ^17 || ^18 || ^19
|
||||
react-dom: ^17 || ^18 || ^19
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@base-ui/utils@0.2.4':
|
||||
resolution: {integrity: sha512-smZwpMhjO29v+jrZusBSc5T+IJ3vBb9cjIiBjtKcvWmRj9Z4DWGVR3efr1eHR56/bqY5a4qyY9ElkOY5ljo3ng==}
|
||||
peerDependencies:
|
||||
'@types/react': ^17 || ^18 || ^19
|
||||
react: ^17 || ^18 || ^19
|
||||
react-dom: ^17 || ^18 || ^19
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@bentocache/plugin-prometheus@0.2.0':
|
||||
resolution: {integrity: sha512-ZaWtexpwDf6cSy2dZaRl36BAZi1eSM8QDnGeJQ0qN7rJ6TEvrP3v0egH70Gxc5mdHY7xhh0Zppf+kAoTgJZx3A==}
|
||||
peerDependencies:
|
||||
|
|
@ -3961,21 +3986,36 @@ packages:
|
|||
'@floating-ui/core@1.2.6':
|
||||
resolution: {integrity: sha512-EvYTiXet5XqweYGClEmpu3BoxmsQ4hkj3QaYA6qEnigCWffTP3vNRwBReTdrwDwo7OoJ3wM8Uoe9Uk4n+d4hfg==}
|
||||
|
||||
'@floating-ui/core@1.7.4':
|
||||
resolution: {integrity: sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==}
|
||||
|
||||
'@floating-ui/dom@1.2.9':
|
||||
resolution: {integrity: sha512-sosQxsqgxMNkV3C+3UqTS6LxP7isRLwX8WMepp843Rb3/b0Wz8+MdUkxJksByip3C2WwLugLHN1b4ibn//zKwQ==}
|
||||
|
||||
'@floating-ui/dom@1.7.5':
|
||||
resolution: {integrity: sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==}
|
||||
|
||||
'@floating-ui/react-dom@2.1.0':
|
||||
resolution: {integrity: sha512-lNzj5EQmEKn5FFKc04+zasr09h/uX8RtJRNj5gUXsSQIXHVWTVh+hVAg1vOMCexkX8EgvemMvIFpQfkosnVNyA==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
react-dom: '>=16.8.0'
|
||||
|
||||
'@floating-ui/react-dom@2.1.7':
|
||||
resolution: {integrity: sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
react-dom: '>=16.8.0'
|
||||
|
||||
'@floating-ui/react@0.26.16':
|
||||
resolution: {integrity: sha512-HEf43zxZNAI/E781QIVpYSF3K2VH4TTYZpqecjdsFkjsaU1EbaWcM++kw0HXFffj7gDUcBFevX8s0rQGQpxkow==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
react-dom: '>=16.8.0'
|
||||
|
||||
'@floating-ui/utils@0.2.10':
|
||||
resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==}
|
||||
|
||||
'@floating-ui/utils@0.2.2':
|
||||
resolution: {integrity: sha512-J4yDIIthosAsRZ5CPYP/jQvUAQtlZTTD/4suA08/FEnlxqW3sKS9iAhgsa9VYLZ6vDHn/ixJgIqRQPotoBjxIw==}
|
||||
|
||||
|
|
@ -9359,11 +9399,11 @@ packages:
|
|||
react: '>=16.8'
|
||||
react-dom: '>=16.8'
|
||||
|
||||
'@tanstack/react-virtual@3.8.1':
|
||||
resolution: {integrity: sha512-dP5a7giEM4BQWLJ7K07ToZv8rF51mzbrBMkf0scg1QNYuFx3utnPUBPUHdzaowZhIez1K2XS78amuzD+YGRA5Q==}
|
||||
'@tanstack/react-virtual@3.13.18':
|
||||
resolution: {integrity: sha512-dZkhyfahpvlaV0rIKnvQiVoWPyURppl6w4m9IwMDpuIjcJ1sD9YGWrt0wISvgU7ewACXx2Ct46WPgI6qAD4v6A==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0
|
||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
'@tanstack/router-core@1.139.13':
|
||||
resolution: {integrity: sha512-vqBEBiFHHGt82fdtMqGRQFs9BRE8UKI17pVoYurEpIxafI7t8go1LoIxYbva2l8Q+44z0NZNQ2kqVZJwtEwgkg==}
|
||||
|
|
@ -9404,8 +9444,8 @@ packages:
|
|||
resolution: {integrity: sha512-P9dF7XbibHph2PFRz8gfBKEXEY/HJPOhym8CHmjF8y3q5mWpKx9xtZapXQUWCgkqvsK0R46Azuz+VaxD4Xl+Tg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
'@tanstack/virtual-core@3.8.1':
|
||||
resolution: {integrity: sha512-uNtAwenT276M9QYCjTBoHZ8X3MUeCRoGK59zPi92hMIxdfS9AyHjkDWJ94WroDxnv48UE+hIeo21BU84jKc8aQ==}
|
||||
'@tanstack/virtual-core@3.13.18':
|
||||
resolution: {integrity: sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg==}
|
||||
|
||||
'@tanstack/zod-adapter@1.120.5':
|
||||
resolution: {integrity: sha512-EXFVr2rw9qy5Ad9fogxo++A10A555XrhNyKZ7pnPV84HU/Xy3C2zP8UaaoTlTDr+/BJ2yzyyM47yK62a03ofbA==}
|
||||
|
|
@ -9906,9 +9946,6 @@ packages:
|
|||
'@types/react-virtualized-auto-sizer@1.0.4':
|
||||
resolution: {integrity: sha512-nhYwlFiYa8M3S+O2T9QO/e1FQUYMr/wJENUdf/O0dhRi1RS/93rjrYQFYdbUqtdFySuhrtnEDX29P6eKOttY+A==}
|
||||
|
||||
'@types/react-window@1.8.8':
|
||||
resolution: {integrity: sha512-8Ls660bHR1AUA2kuRvVG9D/4XpRC6wjAaPT9dil7Ckc76eP9TKWZwwmgfq8Q1LANX3QNDnoU4Zp48A3w+zK69Q==}
|
||||
|
||||
'@types/react@18.3.18':
|
||||
resolution: {integrity: sha512-t4yC+vtgnkYjNSKlFx1jkAhH8LgTo2N/7Qvi83kdEaUtMDiwpbLAktKDaAMlRcJ5eSxZkH74eEGt1ky31d7kfQ==}
|
||||
|
||||
|
|
@ -16815,13 +16852,6 @@ packages:
|
|||
react: '>=16 || >=17 || >= 18'
|
||||
react-dom: '>=16 || >=17 || >= 18'
|
||||
|
||||
react-window@1.8.11:
|
||||
resolution: {integrity: sha512-+SRbUVT2scadgFSWx+R1P754xHPEqvcfSfVX10QYg6POOz+WNgkN48pS+BtZNIMGiL1HYrSEiCkwsMS15QogEQ==}
|
||||
engines: {node: '>8.0.0'}
|
||||
peerDependencies:
|
||||
react: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
react@18.3.1:
|
||||
resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
|
@ -17063,6 +17093,9 @@ packages:
|
|||
requires-port@1.0.0:
|
||||
resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==}
|
||||
|
||||
reselect@5.1.1:
|
||||
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
|
||||
|
||||
resolve-alpn@1.2.1:
|
||||
resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==}
|
||||
|
||||
|
|
@ -17884,6 +17917,9 @@ packages:
|
|||
tabbable@6.2.0:
|
||||
resolution: {integrity: sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==}
|
||||
|
||||
tabbable@6.4.0:
|
||||
resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==}
|
||||
|
||||
tagged-tag@1.0.0:
|
||||
resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==}
|
||||
engines: {node: '>=20'}
|
||||
|
|
@ -21284,6 +21320,8 @@ snapshots:
|
|||
dependencies:
|
||||
regenerator-runtime: 0.14.1
|
||||
|
||||
'@babel/runtime@7.28.6': {}
|
||||
|
||||
'@babel/template@7.26.9':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.27.1
|
||||
|
|
@ -21320,6 +21358,31 @@ snapshots:
|
|||
|
||||
'@balena/dockerignore@1.0.2': {}
|
||||
|
||||
'@base-ui/react@1.1.0(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.6
|
||||
'@base-ui/utils': 0.2.4(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@floating-ui/react-dom': 2.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@floating-ui/utils': 0.2.10
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
reselect: 5.1.1
|
||||
tabbable: 6.4.0
|
||||
use-sync-external-store: 1.6.0(react@18.3.1)
|
||||
optionalDependencies:
|
||||
'@types/react': 18.3.18
|
||||
|
||||
'@base-ui/utils@0.2.4(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.6
|
||||
'@floating-ui/utils': 0.2.10
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
reselect: 5.1.1
|
||||
use-sync-external-store: 1.6.0(react@18.3.1)
|
||||
optionalDependencies:
|
||||
'@types/react': 18.3.18
|
||||
|
||||
'@bentocache/plugin-prometheus@0.2.0(bentocache@1.1.0(patch_hash=98c0f93795fdd4f5eae32ee7915de8e9a346a24c3a917262b1f4551190f1a1af)(ioredis@5.8.2))(prom-client@15.1.3)':
|
||||
dependencies:
|
||||
bentocache: 1.1.0(patch_hash=98c0f93795fdd4f5eae32ee7915de8e9a346a24c3a917262b1f4551190f1a1af)(ioredis@5.8.2)
|
||||
|
|
@ -22165,10 +22228,19 @@ snapshots:
|
|||
|
||||
'@floating-ui/core@1.2.6': {}
|
||||
|
||||
'@floating-ui/core@1.7.4':
|
||||
dependencies:
|
||||
'@floating-ui/utils': 0.2.10
|
||||
|
||||
'@floating-ui/dom@1.2.9':
|
||||
dependencies:
|
||||
'@floating-ui/core': 1.2.6
|
||||
|
||||
'@floating-ui/dom@1.7.5':
|
||||
dependencies:
|
||||
'@floating-ui/core': 1.7.4
|
||||
'@floating-ui/utils': 0.2.10
|
||||
|
||||
'@floating-ui/react-dom@2.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
'@floating-ui/dom': 1.2.9
|
||||
|
|
@ -22181,6 +22253,12 @@ snapshots:
|
|||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
|
||||
'@floating-ui/react-dom@2.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
'@floating-ui/dom': 1.7.5
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
|
||||
'@floating-ui/react@0.26.16(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
'@floating-ui/react-dom': 2.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
|
|
@ -22197,6 +22275,8 @@ snapshots:
|
|||
react-dom: 19.2.4(react@19.2.4)
|
||||
tabbable: 6.2.0
|
||||
|
||||
'@floating-ui/utils@0.2.10': {}
|
||||
|
||||
'@floating-ui/utils@0.2.2': {}
|
||||
|
||||
'@formatjs/intl-localematcher@0.5.10':
|
||||
|
|
@ -24817,7 +24897,7 @@ snapshots:
|
|||
|
||||
'@headlessui/react@1.7.19(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
'@tanstack/react-virtual': 3.8.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@tanstack/react-virtual': 3.13.18(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
client-only: 0.0.1
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
|
|
@ -24827,7 +24907,7 @@ snapshots:
|
|||
'@floating-ui/react': 0.26.16(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@react-aria/focus': 3.17.1(react@18.3.1)
|
||||
'@react-aria/interactions': 3.21.3(react@18.3.1)
|
||||
'@tanstack/react-virtual': 3.8.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@tanstack/react-virtual': 3.13.18(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
|
||||
|
|
@ -24836,7 +24916,7 @@ snapshots:
|
|||
'@floating-ui/react': 0.26.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@react-aria/focus': 3.17.1(react@19.2.4)
|
||||
'@react-aria/interactions': 3.21.3(react@19.2.4)
|
||||
'@tanstack/react-virtual': 3.8.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@tanstack/react-virtual': 3.13.18(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
|
||||
|
|
@ -30193,15 +30273,15 @@ snapshots:
|
|||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
|
||||
'@tanstack/react-virtual@3.8.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||
'@tanstack/react-virtual@3.13.18(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
'@tanstack/virtual-core': 3.8.1
|
||||
'@tanstack/virtual-core': 3.13.18
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
|
||||
'@tanstack/react-virtual@3.8.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
'@tanstack/react-virtual@3.13.18(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@tanstack/virtual-core': 3.8.1
|
||||
'@tanstack/virtual-core': 3.13.18
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
|
||||
|
|
@ -30259,7 +30339,7 @@ snapshots:
|
|||
|
||||
'@tanstack/table-core@8.20.5': {}
|
||||
|
||||
'@tanstack/virtual-core@3.8.1': {}
|
||||
'@tanstack/virtual-core@3.13.18': {}
|
||||
|
||||
'@tanstack/zod-adapter@1.120.5(@tanstack/react-router@1.34.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(zod@3.25.76)':
|
||||
dependencies:
|
||||
|
|
@ -30893,10 +30973,6 @@ snapshots:
|
|||
dependencies:
|
||||
'@types/react': 18.3.18
|
||||
|
||||
'@types/react-window@1.8.8':
|
||||
dependencies:
|
||||
'@types/react': 18.3.18
|
||||
|
||||
'@types/react@18.3.18':
|
||||
dependencies:
|
||||
'@types/prop-types': 15.7.5
|
||||
|
|
@ -39039,9 +39115,9 @@ snapshots:
|
|||
minimist: 1.2.8
|
||||
strip-json-comments: 2.0.1
|
||||
|
||||
react-avatar@5.0.3(@babel/runtime@7.26.10)(core-js-pure@3.37.1)(prop-types@15.8.1)(react@19.2.4):
|
||||
react-avatar@5.0.3(@babel/runtime@7.28.6)(core-js-pure@3.37.1)(prop-types@15.8.1)(react@19.2.4):
|
||||
dependencies:
|
||||
'@babel/runtime': 7.26.10
|
||||
'@babel/runtime': 7.28.6
|
||||
core-js-pure: 3.37.1
|
||||
is-retina: 1.0.3
|
||||
md5: 2.3.0
|
||||
|
|
@ -39261,13 +39337,6 @@ snapshots:
|
|||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
|
||||
react-window@1.8.11(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||
dependencies:
|
||||
'@babel/runtime': 7.26.10
|
||||
memoize-one: 5.2.1
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
|
||||
react@18.3.1:
|
||||
dependencies:
|
||||
loose-envify: 1.4.0
|
||||
|
|
@ -39628,6 +39697,8 @@ snapshots:
|
|||
|
||||
requires-port@1.0.0: {}
|
||||
|
||||
reselect@5.1.1: {}
|
||||
|
||||
resolve-alpn@1.2.1: {}
|
||||
|
||||
resolve-from@4.0.0: {}
|
||||
|
|
@ -40651,6 +40722,8 @@ snapshots:
|
|||
|
||||
tabbable@6.2.0: {}
|
||||
|
||||
tabbable@6.4.0: {}
|
||||
|
||||
tagged-tag@1.0.0: {}
|
||||
|
||||
tailwind-csstree@0.1.4: {}
|
||||
|
|
|
|||
906
scripts/seed-insights.mts
Normal file
906
scripts/seed-insights.mts
Normal file
|
|
@ -0,0 +1,906 @@
|
|||
/**
|
||||
* Seeds a complete Insights development environment from scratch.
|
||||
*
|
||||
* Creates: owner account, org, project, target, schema, usage data (30 days),
|
||||
* and saved filters with view counts.
|
||||
*
|
||||
* Prerequisites:
|
||||
* - Docker Compose is running (pnpm local:setup)
|
||||
* - Services are running (pnpm dev:hive)
|
||||
*
|
||||
* Usage:
|
||||
* bun scripts/seed-insights.mts
|
||||
*/
|
||||
|
||||
import * as readline from 'node:readline/promises';
|
||||
import { createTRPCProxyClient, httpLink } from '@trpc/client';
|
||||
import type { CollectedOperation } from '../integration-tests/testkit/usage';
|
||||
|
||||
process.env.RUN_AGAINST_LOCAL_SERVICES = '1';
|
||||
await import('../integration-tests/local-dev.ts');
|
||||
|
||||
const { ensureEnv } = await import('../integration-tests/testkit/env');
|
||||
const { createOrganization, createProject, createToken, publishSchema } = await import(
|
||||
'../integration-tests/testkit/flow'
|
||||
);
|
||||
const { execute } = await import('../integration-tests/testkit/graphql');
|
||||
const { legacyCollect } = await import('../integration-tests/testkit/usage');
|
||||
const { generateUnique, getServiceHost } = await import('../integration-tests/testkit/utils');
|
||||
const { TargetAccessScope, ProjectType, SavedFilterVisibilityType } = await import(
|
||||
'../integration-tests/testkit/gql/graphql'
|
||||
);
|
||||
const { CreateSavedFilterMutation, TrackSavedFilterViewMutation } = await import(
|
||||
'../integration-tests/testkit/saved-filters'
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auth helper — handles both new and existing SuperTokens users
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const password = 'ilikebigturtlesandicannotlie47';
|
||||
|
||||
async function signInOrSignUp(
|
||||
email: string,
|
||||
): Promise<{ access_token: string; refresh_token: string }> {
|
||||
const supertokensUri = ensureEnv('SUPERTOKENS_CONNECTION_URI');
|
||||
const apiKey = ensureEnv('SUPERTOKENS_API_KEY');
|
||||
const headers = {
|
||||
'content-type': 'application/json; charset=UTF-8',
|
||||
'api-key': apiKey,
|
||||
'cdi-version': '4.0',
|
||||
};
|
||||
const body = JSON.stringify({ email, password });
|
||||
|
||||
// Try signup first
|
||||
let res = await fetch(`${supertokensUri}/appid-public/public/recipe/signup`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
let data = (await res.json()) as { status: string; user?: { id: string; emails: string[] } };
|
||||
|
||||
// If user already exists, look them up by email (avoids needing their password)
|
||||
if (data.status === 'EMAIL_ALREADY_EXISTS_ERROR') {
|
||||
res = await fetch(
|
||||
`${supertokensUri}/appid-public/public/recipe/user?email=${encodeURIComponent(email)}`,
|
||||
{ headers },
|
||||
);
|
||||
const lookupData = (await res.json()) as {
|
||||
status: string;
|
||||
user?: { id: string; emails: string[] };
|
||||
};
|
||||
if (lookupData.status !== 'OK' || !lookupData.user) {
|
||||
throw new Error(`User lookup failed for ${email}: ${JSON.stringify(lookupData)}`);
|
||||
}
|
||||
data = { status: 'OK', user: lookupData.user };
|
||||
}
|
||||
|
||||
if (data.status !== 'OK' || !data.user) {
|
||||
throw new Error(`Auth failed for ${email}: ${JSON.stringify(data)}`);
|
||||
}
|
||||
|
||||
const superTokensUserId = data.user.id;
|
||||
|
||||
// Ensure user exists in Hive DB
|
||||
const graphqlAddress = await getServiceHost('server', 8082);
|
||||
const internalApi = createTRPCProxyClient<any>({
|
||||
links: [httpLink({ url: `http://${graphqlAddress}/trpc`, fetch })],
|
||||
});
|
||||
const ensureUserResult = await internalApi.ensureUser.mutate({
|
||||
superTokensUserId,
|
||||
email,
|
||||
oidcIntegrationId: null,
|
||||
firstName: null,
|
||||
lastName: null,
|
||||
});
|
||||
if (!ensureUserResult.ok) {
|
||||
throw new Error(`ensureUser failed: ${ensureUserResult.reason}`);
|
||||
}
|
||||
|
||||
// Create session
|
||||
const sessionPayload = {
|
||||
version: '2',
|
||||
superTokensUserId,
|
||||
userId: ensureUserResult.user.id,
|
||||
oidcIntegrationId: null,
|
||||
email,
|
||||
};
|
||||
const sessionRes = await fetch(`${supertokensUri}/appid-public/public/recipe/session`, {
|
||||
method: 'POST',
|
||||
headers: { ...headers, rid: 'session' },
|
||||
body: JSON.stringify({
|
||||
enableAntiCsrf: false,
|
||||
userId: superTokensUserId,
|
||||
userDataInDatabase: sessionPayload,
|
||||
userDataInJWT: sessionPayload,
|
||||
}),
|
||||
});
|
||||
const sessionData = (await sessionRes.json()) as {
|
||||
accessToken?: { token: string };
|
||||
refreshToken?: { token: string };
|
||||
};
|
||||
|
||||
if (!sessionData.accessToken?.token || !sessionData.refreshToken?.token) {
|
||||
throw new Error(`Session creation failed: ${JSON.stringify(sessionData)}`);
|
||||
}
|
||||
|
||||
return {
|
||||
access_token: sessionData.accessToken.token,
|
||||
refresh_token: sessionData.refreshToken.token,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Operations — ~1000 distinct queries/mutations against the Star Wars schema
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type OperationDef = {
|
||||
operation: string;
|
||||
operationName: string;
|
||||
fields: string[];
|
||||
};
|
||||
|
||||
const EPISODES = ['NEWHOPE', 'EMPIRE', 'JEDI'] as const;
|
||||
|
||||
// Field selection templates for Character
|
||||
const CHARACTER_SELECTIONS = [
|
||||
{ body: 'name', fields: ['Character', 'Character.name'] },
|
||||
{ body: 'name appearsIn', fields: ['Character', 'Character.name', 'Character.appearsIn'] },
|
||||
{ body: 'name friends { name }', fields: ['Character', 'Character.name', 'Character.friends'] },
|
||||
{
|
||||
body: 'name appearsIn friends { name }',
|
||||
fields: ['Character', 'Character.name', 'Character.appearsIn', 'Character.friends'],
|
||||
},
|
||||
{
|
||||
body: 'name friends { name appearsIn }',
|
||||
fields: ['Character', 'Character.name', 'Character.friends', 'Character.appearsIn'],
|
||||
},
|
||||
{
|
||||
body: 'name friends { name friends { name } }',
|
||||
fields: ['Character', 'Character.name', 'Character.friends'],
|
||||
},
|
||||
];
|
||||
|
||||
// Inline fragment templates
|
||||
const HUMAN_SELECTIONS = [
|
||||
{
|
||||
body: '... on Human { name starships { name } }',
|
||||
fields: ['Human', 'Human.name', 'Human.starships', 'Starship', 'Starship.name'],
|
||||
},
|
||||
{
|
||||
body: '... on Human { name totalCredits }',
|
||||
fields: ['Human', 'Human.name', 'Human.totalCredits'],
|
||||
},
|
||||
{
|
||||
body: '... on Human { name starships { name length } }',
|
||||
fields: [
|
||||
'Human',
|
||||
'Human.name',
|
||||
'Human.starships',
|
||||
'Starship',
|
||||
'Starship.name',
|
||||
'Starship.length',
|
||||
],
|
||||
},
|
||||
{
|
||||
body: '... on Human { name totalCredits starships { name } }',
|
||||
fields: [
|
||||
'Human',
|
||||
'Human.name',
|
||||
'Human.totalCredits',
|
||||
'Human.starships',
|
||||
'Starship',
|
||||
'Starship.name',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const DROID_SELECTIONS = [
|
||||
{
|
||||
body: '... on Droid { name primaryFunction }',
|
||||
fields: ['Droid', 'Droid.name', 'Droid.primaryFunction'],
|
||||
},
|
||||
{
|
||||
body: '... on Droid { name }',
|
||||
fields: ['Droid', 'Droid.name'],
|
||||
},
|
||||
];
|
||||
|
||||
const REVIEW_SELECTIONS = [
|
||||
{ body: 'stars', fields: ['Review', 'Review.stars'] },
|
||||
{ body: 'stars commentary', fields: ['Review', 'Review.stars', 'Review.commentary'] },
|
||||
{ body: 'episode stars', fields: ['Review', 'Review.episode', 'Review.stars'] },
|
||||
{
|
||||
body: 'episode stars commentary',
|
||||
fields: ['Review', 'Review.episode', 'Review.stars', 'Review.commentary'],
|
||||
},
|
||||
];
|
||||
|
||||
function generateOperations(): OperationDef[] {
|
||||
const ops: OperationDef[] = [];
|
||||
let idx = 0;
|
||||
|
||||
// 1. Simple hero queries per episode x character selection (3 × 6 = 18)
|
||||
for (const ep of EPISODES) {
|
||||
for (const sel of CHARACTER_SELECTIONS) {
|
||||
const name = `GetHero_${idx++}`;
|
||||
ops.push({
|
||||
operation: `query ${name} { hero(episode: ${ep}) { ${sel.body} } }`,
|
||||
operationName: name,
|
||||
fields: ['Query', 'Query.hero', ...sel.fields],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Human fragment queries per episode (3 × 4 = 12)
|
||||
for (const ep of EPISODES) {
|
||||
for (const sel of HUMAN_SELECTIONS) {
|
||||
const name = `GetHuman_${idx++}`;
|
||||
ops.push({
|
||||
operation: `query ${name} { hero(episode: ${ep}) { ${sel.body} } }`,
|
||||
operationName: name,
|
||||
fields: ['Query', 'Query.hero', ...sel.fields],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Droid fragment queries per episode (3 × 2 = 6)
|
||||
for (const ep of EPISODES) {
|
||||
for (const sel of DROID_SELECTIONS) {
|
||||
const name = `GetDroid_${idx++}`;
|
||||
ops.push({
|
||||
operation: `query ${name} { hero(episode: ${ep}) { ${sel.body} } }`,
|
||||
operationName: name,
|
||||
fields: ['Query', 'Query.hero', ...sel.fields],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Combined Human + Droid queries per episode (3 × 4 × 2 = 24)
|
||||
for (const ep of EPISODES) {
|
||||
for (const hSel of HUMAN_SELECTIONS) {
|
||||
for (const dSel of DROID_SELECTIONS) {
|
||||
const name = `GetCharacterDetails_${idx++}`;
|
||||
ops.push({
|
||||
operation: `query ${name} { hero(episode: ${ep}) { name ${hSel.body} ${dSel.body} } }`,
|
||||
operationName: name,
|
||||
fields: [
|
||||
'Query',
|
||||
'Query.hero',
|
||||
'Character',
|
||||
'Character.name',
|
||||
...hSel.fields,
|
||||
...dSel.fields,
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Multi-alias queries — different episode combos (3 choose 2 = 3, with varying selections = ~18)
|
||||
const epPairs: [string, string][] = [
|
||||
['NEWHOPE', 'EMPIRE'],
|
||||
['NEWHOPE', 'JEDI'],
|
||||
['EMPIRE', 'JEDI'],
|
||||
];
|
||||
for (const [ep1, ep2] of epPairs) {
|
||||
for (const sel of CHARACTER_SELECTIONS) {
|
||||
const name = `Compare_${idx++}`;
|
||||
ops.push({
|
||||
operation: `query ${name} { a: hero(episode: ${ep1}) { ${sel.body} } b: hero(episode: ${ep2}) { ${sel.body} } }`,
|
||||
operationName: name,
|
||||
fields: ['Query', 'Query.hero', ...sel.fields],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Triple-alias queries (1 × 6 = 6)
|
||||
for (const sel of CHARACTER_SELECTIONS) {
|
||||
const name = `AllEpisodeHeroes_${idx++}`;
|
||||
ops.push({
|
||||
operation: `query ${name} { newhope: hero(episode: NEWHOPE) { ${sel.body} } empire: hero(episode: EMPIRE) { ${sel.body} } jedi: hero(episode: JEDI) { ${sel.body} } }`,
|
||||
operationName: name,
|
||||
fields: ['Query', 'Query.hero', ...sel.fields],
|
||||
});
|
||||
}
|
||||
|
||||
// 7. Mutation variations — createReview per episode x review selection (3 × 4 = 12)
|
||||
const STARS = [1, 2, 3, 4, 5];
|
||||
for (const ep of EPISODES) {
|
||||
for (const sel of REVIEW_SELECTIONS) {
|
||||
const name = `CreateReview_${idx++}`;
|
||||
const stars = STARS[idx % STARS.length];
|
||||
ops.push({
|
||||
operation: `mutation ${name} { createReview(episode: ${ep}, review: { stars: ${stars} }) { ${sel.body} } }`,
|
||||
operationName: name,
|
||||
fields: ['Mutation', 'Mutation.createReview', ...sel.fields],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Generate more unique queries to reach ~1000 by varying naming patterns
|
||||
// Simulate realistic operation names like a real codebase would have
|
||||
// Mix of short and long prefixes to test truncation at varying widths
|
||||
const PREFIXES = [
|
||||
'Dashboard',
|
||||
'Settings',
|
||||
'Profile',
|
||||
'Admin',
|
||||
'Search',
|
||||
'Feed',
|
||||
'Sync',
|
||||
'OrganizationBillingSubscriptionDetails',
|
||||
'TargetSchemaVersionComparison',
|
||||
'ProjectAccessTokenPermissionsManagement',
|
||||
'UserNotificationPreferencesUpdate',
|
||||
'SchemaRegistryExplorerTypeDetails',
|
||||
'IntegrationWebhookDeliveryStatus',
|
||||
'AlertChannelConfigurationValidation',
|
||||
'PersistedOperationCollectionSync',
|
||||
'GraphQLEndpointLatencyPercentiles',
|
||||
'CDNAccessTokenRotation',
|
||||
'SchemaContractCompositionValidation',
|
||||
'MemberRoleAssignmentAuditLog',
|
||||
'OperationBodyNormalizationPreview',
|
||||
];
|
||||
const SUFFIXES = [
|
||||
'Query',
|
||||
'Fetch',
|
||||
'Load',
|
||||
'Get',
|
||||
'List',
|
||||
'Detail',
|
||||
'Summary',
|
||||
'Overview',
|
||||
'Stats',
|
||||
'Count',
|
||||
'WithPaginationAndFilters',
|
||||
'ByOrganizationSlug',
|
||||
'ForDateRangeComparison',
|
||||
];
|
||||
|
||||
while (ops.length < 1000) {
|
||||
const prefix = PREFIXES[idx % PREFIXES.length];
|
||||
const suffix = SUFFIXES[Math.floor(idx / PREFIXES.length) % SUFFIXES.length];
|
||||
const ep = EPISODES[idx % 3];
|
||||
const charSel = CHARACTER_SELECTIONS[idx % CHARACTER_SELECTIONS.length];
|
||||
const name = `${prefix}${suffix}_${idx++}`;
|
||||
ops.push({
|
||||
operation: `query ${name} { hero(episode: ${ep}) { ${charSel.body} } }`,
|
||||
operationName: name,
|
||||
fields: ['Query', 'Query.hero', ...charSel.fields],
|
||||
});
|
||||
}
|
||||
|
||||
return ops;
|
||||
}
|
||||
|
||||
const OPERATIONS = generateOperations();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Clients — 7 clients with 0–30 versions each
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ClientDef {
|
||||
name: string;
|
||||
versions: string[];
|
||||
weight: number; // relative traffic weight
|
||||
}
|
||||
|
||||
function generateVersions(prefix: string, count: number): string[] {
|
||||
return Array.from({ length: count }, (_, i) => `${prefix}.${i}.0`);
|
||||
}
|
||||
|
||||
// Mix of short and long client names to test truncation at varying widths
|
||||
const CLIENTS: ClientDef[] = [
|
||||
{ name: 'web-app', versions: generateVersions('1', 15), weight: 30 },
|
||||
{ name: 'ios', versions: generateVersions('2', 25), weight: 25 },
|
||||
{ name: 'android', versions: generateVersions('3', 10), weight: 15 },
|
||||
{ name: 'graphql-playground', versions: [], weight: 5 },
|
||||
{ name: 'admin-dashboard-internal-tools', versions: generateVersions('1', 5), weight: 8 },
|
||||
{
|
||||
name: 'mobile-backend-for-frontend-service',
|
||||
versions: generateVersions('0', 30),
|
||||
weight: 12,
|
||||
},
|
||||
{ name: 'analytics-pipeline-worker-v2', versions: generateVersions('1', 8), weight: 5 },
|
||||
];
|
||||
|
||||
const TOTAL_WEIGHT = CLIENTS.reduce((s, c) => s + c.weight, 0);
|
||||
|
||||
function pickWeightedClient(): { name: string; version: string | undefined } {
|
||||
let r = Math.random() * TOTAL_WEIGHT;
|
||||
for (const client of CLIENTS) {
|
||||
r -= client.weight;
|
||||
if (r <= 0) {
|
||||
const version =
|
||||
client.versions.length > 0
|
||||
? client.versions[Math.floor(Math.random() * client.versions.length)]
|
||||
: undefined;
|
||||
return { name: client.name, version };
|
||||
}
|
||||
}
|
||||
// fallback
|
||||
return { name: CLIENTS[0].name, version: CLIENTS[0].versions[0] };
|
||||
}
|
||||
|
||||
function randomDuration(): number {
|
||||
// 50ms to 2s in nanoseconds, with most clustering around 100-500ms
|
||||
const base = 50 + Math.random() * 450; // 50-500ms
|
||||
const spike = Math.random() < 0.1 ? Math.random() * 1500 : 0; // 10% chance of slow
|
||||
return Math.round((base + spike) * 1_000_000); // convert ms → ns
|
||||
}
|
||||
|
||||
function randomOperation() {
|
||||
return OPERATIONS[Math.floor(Math.random() * OPERATIONS.length)];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. Schema SDL (Star Wars mono — matches scripts/seed-schemas/mono.graphql)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SCHEMA_SDL = `
|
||||
interface Node { id: ID! }
|
||||
interface Character implements Node { id: ID! name: String! friends: [Character] appearsIn: [Episode]! }
|
||||
type Human implements Character & Node { id: ID! name: String! friends: [Character] appearsIn: [Episode]! starships: [Starship] totalCredits: Int }
|
||||
type Droid implements Character & Node { id: ID! name: String! friends: [Character] appearsIn: [Episode]! primaryFunction: String }
|
||||
type Starship { id: ID! name: String! length(unit: LengthUnit = METER): Float }
|
||||
enum LengthUnit { METER LIGHT_YEAR }
|
||||
enum Episode { NEWHOPE EMPIRE JEDI }
|
||||
type Query { hero(episode: Episode): Character }
|
||||
type Review { episode: Episode stars: Int! commentary: String }
|
||||
input ReviewInput { stars: Int! commentary: String }
|
||||
type Mutation { createReview(episode: Episode, review: ReviewInput!): Review }
|
||||
`.trim();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. Prompt + Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const BATCH_SIZE = 500;
|
||||
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
const ONE_HOUR_MS = 60 * 60 * 1000;
|
||||
|
||||
async function promptForEmail(): Promise<string> {
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
try {
|
||||
const email = await rl.question('Enter owner email (or press Enter to auto-generate): ');
|
||||
return email.trim();
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const inputEmail = await promptForEmail();
|
||||
const ownerEmail = inputEmail || `${generateUnique()}-${Date.now()}@localhost.localhost`;
|
||||
|
||||
console.log(`\n🚀 Creating owner (${ownerEmail}), org, project...`);
|
||||
const auth = await signInOrSignUp(ownerEmail);
|
||||
const ownerToken = auth.access_token;
|
||||
|
||||
// Create organization
|
||||
const orgSlug = generateUnique();
|
||||
const orgResult = await createOrganization({ slug: orgSlug }, ownerToken).then(r =>
|
||||
r.expectNoGraphQLErrors(),
|
||||
);
|
||||
const organization = orgResult.createOrganization.ok!.createdOrganizationPayload.organization;
|
||||
|
||||
// Create project
|
||||
const projectResult = await createProject(
|
||||
{
|
||||
organization: { bySelector: { organizationSlug: organization.slug } },
|
||||
type: ProjectType.Single,
|
||||
slug: generateUnique(),
|
||||
},
|
||||
ownerToken,
|
||||
).then(r => r.expectNoGraphQLErrors());
|
||||
|
||||
const project = projectResult.createProject.ok!.createdProject;
|
||||
const target = projectResult.createProject.ok!.createdTargets[0];
|
||||
|
||||
console.log(` Org: ${organization.slug}`);
|
||||
console.log(` Project: ${project.slug}`);
|
||||
console.log(` Target: ${target.slug}`);
|
||||
|
||||
// Create access token
|
||||
console.log('📝 Publishing schema...');
|
||||
const tokenResult = await createToken(
|
||||
{
|
||||
name: generateUnique(),
|
||||
organizationSlug: organization.slug,
|
||||
projectSlug: project.slug,
|
||||
targetSlug: target.slug,
|
||||
organizationScopes: [],
|
||||
projectScopes: [],
|
||||
targetScopes: [TargetAccessScope.RegistryRead, TargetAccessScope.RegistryWrite],
|
||||
},
|
||||
ownerToken,
|
||||
).then(r => r.expectNoGraphQLErrors());
|
||||
|
||||
const secret = tokenResult.createToken.ok!.secret;
|
||||
|
||||
const publishResult = await publishSchema(
|
||||
{
|
||||
author: 'seed-insights',
|
||||
commit: 'seed',
|
||||
sdl: SCHEMA_SDL,
|
||||
force: true,
|
||||
},
|
||||
secret,
|
||||
'authorization',
|
||||
);
|
||||
if (publishResult.rawBody.errors?.length) {
|
||||
console.error('Schema publish failed:', publishResult.rawBody.errors);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(' Schema published successfully.');
|
||||
|
||||
// Generate operations spread across 30 days
|
||||
console.log('📊 Generating usage data (30 days)...');
|
||||
const now = Date.now();
|
||||
const allOperations: CollectedOperation[] = [];
|
||||
|
||||
for (let t = now - THIRTY_DAYS_MS; t <= now; t += ONE_HOUR_MS) {
|
||||
const opsThisHour = 3 + Math.floor(Math.random() * 6); // 3–8 per hour
|
||||
for (let i = 0; i < opsThisHour; i++) {
|
||||
const op = randomOperation();
|
||||
const client = pickWeightedClient();
|
||||
const ok = Math.random() > 0.05;
|
||||
allOperations.push({
|
||||
timestamp: t + Math.floor(Math.random() * ONE_HOUR_MS),
|
||||
operation: op.operation,
|
||||
operationName: op.operationName,
|
||||
fields: op.fields,
|
||||
execution: {
|
||||
ok,
|
||||
duration: randomDuration(),
|
||||
errorsTotal: ok ? 0 : 1,
|
||||
},
|
||||
metadata: {
|
||||
client: {
|
||||
name: client.name,
|
||||
...(client.version ? { version: client.version } : {}),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log(` Generated ${allOperations.length} operations across 30 days.`);
|
||||
|
||||
// Send in batches
|
||||
const totalBatches = Math.ceil(allOperations.length / BATCH_SIZE);
|
||||
for (let i = 0; i < allOperations.length; i += BATCH_SIZE) {
|
||||
const batch = allOperations.slice(i, i + BATCH_SIZE);
|
||||
const batchNum = Math.floor(i / BATCH_SIZE) + 1;
|
||||
process.stdout.write(` Sending batch ${batchNum}/${totalBatches}...`);
|
||||
const result = await legacyCollect({
|
||||
operations: batch,
|
||||
token: secret,
|
||||
authorizationHeader: 'authorization',
|
||||
});
|
||||
if (result.status !== 200) {
|
||||
console.error(` FAILED (status ${result.status}):`, result.body);
|
||||
} else {
|
||||
const body = result.body as { operations: { accepted: number; rejected: number } };
|
||||
console.log(` ✓ ${body.operations.accepted} accepted, ${body.operations.rejected} rejected`);
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for ingestion
|
||||
console.log('⏳ Waiting for usage ingestion (15s)...');
|
||||
await new Promise(resolve => setTimeout(resolve, 15_000));
|
||||
|
||||
// Helper for saved filter operations
|
||||
const targetSelector = {
|
||||
organizationSlug: organization.slug,
|
||||
projectSlug: project.slug,
|
||||
targetSlug: target.slug,
|
||||
};
|
||||
|
||||
// Fetch actual operation hashes from ingested data
|
||||
console.log('🔍 Fetching operation hashes...');
|
||||
const { parse } = await import('graphql');
|
||||
const opsResult = await execute({
|
||||
document: parse(/* GraphQL */ `
|
||||
query SeedGetOperationHashes($target: TargetReferenceInput!, $period: DateRangeInput!) {
|
||||
target(reference: $target) {
|
||||
operationsStats(period: $period) {
|
||||
operations {
|
||||
edges {
|
||||
node {
|
||||
name
|
||||
operationHash
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`) as any,
|
||||
variables: {
|
||||
target: { bySelector: targetSelector },
|
||||
period: {
|
||||
from: new Date(now - THIRTY_DAYS_MS).toISOString(),
|
||||
to: new Date(now).toISOString(),
|
||||
},
|
||||
},
|
||||
authToken: ownerToken,
|
||||
}).then(r => r.expectNoGraphQLErrors());
|
||||
|
||||
type OpsResult = {
|
||||
target?: {
|
||||
operationsStats?: {
|
||||
operations?: {
|
||||
edges: Array<{ node: { name: string; operationHash: string | null } }>;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
const operationEdges = (opsResult as OpsResult).target?.operationsStats?.operations?.edges ?? [];
|
||||
const operationHashMap = new Map<string, string>();
|
||||
for (const edge of operationEdges) {
|
||||
if (edge.node.operationHash && edge.node.name) {
|
||||
// API returns names as "{hashPrefix}_{operationName}", extract the operationName part
|
||||
const underscoreIdx = edge.node.name.indexOf('_');
|
||||
const operationName =
|
||||
underscoreIdx >= 0 ? edge.node.name.slice(underscoreIdx + 1) : edge.node.name;
|
||||
operationHashMap.set(operationName, edge.node.operationHash);
|
||||
}
|
||||
}
|
||||
console.log(
|
||||
` Found ${operationHashMap.size} operations: ${[...operationHashMap.keys()].join(', ')}`,
|
||||
);
|
||||
|
||||
async function createSavedFilter(input: {
|
||||
name: string;
|
||||
description?: string;
|
||||
visibility: (typeof SavedFilterVisibilityType)[keyof typeof SavedFilterVisibilityType];
|
||||
insightsFilter?: Record<string, unknown>;
|
||||
}) {
|
||||
const result = await execute({
|
||||
document: CreateSavedFilterMutation,
|
||||
variables: {
|
||||
input: {
|
||||
target: { bySelector: targetSelector },
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
visibility: input.visibility,
|
||||
insightsFilter: input.insightsFilter,
|
||||
},
|
||||
},
|
||||
authToken: ownerToken,
|
||||
}).then(r => r.expectNoGraphQLErrors());
|
||||
|
||||
return result.createSavedFilter;
|
||||
}
|
||||
|
||||
async function trackSavedFilterView(filterId: string) {
|
||||
await execute({
|
||||
document: TrackSavedFilterViewMutation,
|
||||
variables: {
|
||||
input: {
|
||||
target: { bySelector: targetSelector },
|
||||
id: filterId,
|
||||
},
|
||||
},
|
||||
authToken: ownerToken,
|
||||
}).then(r => r.expectNoGraphQLErrors());
|
||||
}
|
||||
|
||||
// Create saved filters using real operation hashes
|
||||
console.log('💾 Creating saved filters...');
|
||||
|
||||
// Helper to resolve operation names to hashes
|
||||
function resolveOps(names: string[]): string[] {
|
||||
return names.map(name => operationHashMap.get(name)).filter((h): h is string => h != null);
|
||||
}
|
||||
|
||||
// Collect all known operation names for building saved filters
|
||||
const allOpNames = [...operationHashMap.keys()];
|
||||
console.log(` Using ${allOpNames.length} resolved operations for saved filters`);
|
||||
|
||||
// Define all saved filters (using slices of the resolved operation names)
|
||||
const savedFilterDefs: Array<{
|
||||
name: string;
|
||||
description?: string;
|
||||
visibility: (typeof SavedFilterVisibilityType)[keyof typeof SavedFilterVisibilityType];
|
||||
operationNames: string[];
|
||||
clientFilters?: Array<{ name: string; versions?: string[] }>;
|
||||
dateRange?: { from: string; to: string };
|
||||
views: number;
|
||||
}> = [
|
||||
{
|
||||
name: 'High Traffic Operations',
|
||||
description: 'Most frequently called queries',
|
||||
visibility: SavedFilterVisibilityType.Shared,
|
||||
operationNames: allOpNames.slice(0, 20),
|
||||
clientFilters: [{ name: 'web-app' }, { name: 'ios' }],
|
||||
dateRange: { from: 'now-7d', to: 'now' },
|
||||
views: 372,
|
||||
},
|
||||
{
|
||||
name: 'Mobile Clients',
|
||||
description: 'iOS and Android traffic',
|
||||
visibility: SavedFilterVisibilityType.Shared,
|
||||
operationNames: allOpNames.slice(10, 25),
|
||||
clientFilters: [
|
||||
{ name: 'ios', versions: ['2.0.0', '2.5.0', '2.10.0', '2.20.0'] },
|
||||
{ name: 'android', versions: ['3.0.0', '3.5.0', '3.9.0'] },
|
||||
],
|
||||
dateRange: { from: 'now-30d', to: 'now' },
|
||||
views: 156,
|
||||
},
|
||||
{
|
||||
name: 'My Debug View',
|
||||
description: 'Debugging slow mutations',
|
||||
visibility: SavedFilterVisibilityType.Private,
|
||||
operationNames: allOpNames.slice(30, 35),
|
||||
clientFilters: [{ name: 'graphql-playground' }],
|
||||
dateRange: { from: 'now-1d', to: 'now' },
|
||||
views: 23,
|
||||
},
|
||||
{
|
||||
name: 'Web App — All Queries',
|
||||
description: 'All query operations from the web app',
|
||||
visibility: SavedFilterVisibilityType.Shared,
|
||||
operationNames: allOpNames.slice(0, 50),
|
||||
clientFilters: [{ name: 'web-app' }],
|
||||
dateRange: { from: 'now-7d', to: 'now' },
|
||||
views: 241,
|
||||
},
|
||||
{
|
||||
name: 'Mutations Only',
|
||||
description: 'All mutation operations across all clients',
|
||||
visibility: SavedFilterVisibilityType.Shared,
|
||||
operationNames: allOpNames.filter(n => n.startsWith('CreateReview')),
|
||||
dateRange: { from: 'now-14d', to: 'now' },
|
||||
views: 89,
|
||||
},
|
||||
{
|
||||
name: 'Admin Dashboard',
|
||||
description: 'Operations from the admin dashboard client',
|
||||
visibility: SavedFilterVisibilityType.Shared,
|
||||
operationNames: allOpNames.filter(n => n.startsWith('Dashboard')),
|
||||
clientFilters: [{ name: 'admin-dashboard-internal-tools' }],
|
||||
dateRange: { from: 'now-7d', to: 'now' },
|
||||
views: 64,
|
||||
},
|
||||
{
|
||||
name: 'BFF Layer',
|
||||
description: 'Mobile BFF service traffic',
|
||||
visibility: SavedFilterVisibilityType.Shared,
|
||||
operationNames: allOpNames.slice(50, 80),
|
||||
clientFilters: [{ name: 'mobile-backend-for-frontend-service' }],
|
||||
dateRange: { from: 'now-30d', to: 'now' },
|
||||
views: 118,
|
||||
},
|
||||
{
|
||||
name: 'Error Investigation',
|
||||
description: 'Operations with known error patterns',
|
||||
visibility: SavedFilterVisibilityType.Private,
|
||||
operationNames: allOpNames.slice(80, 90),
|
||||
clientFilters: [{ name: 'android', versions: ['3.0.0'] }],
|
||||
dateRange: { from: 'now-7d', to: 'now' },
|
||||
views: 7,
|
||||
},
|
||||
{
|
||||
name: 'Character Detail Queries',
|
||||
description: 'All character detail operations',
|
||||
visibility: SavedFilterVisibilityType.Shared,
|
||||
operationNames: allOpNames.filter(n => n.startsWith('GetCharacterDetails')),
|
||||
dateRange: { from: 'now-90d', to: 'now' },
|
||||
views: 195,
|
||||
},
|
||||
{
|
||||
name: 'Deep Queries',
|
||||
description: 'Queries with nested friend relationships',
|
||||
visibility: SavedFilterVisibilityType.Shared,
|
||||
operationNames: allOpNames.slice(3, 8),
|
||||
dateRange: { from: 'now-14d', to: 'now' },
|
||||
views: 43,
|
||||
},
|
||||
{
|
||||
name: 'iOS Latest Versions',
|
||||
description: 'Recent iOS app versions only',
|
||||
visibility: SavedFilterVisibilityType.Private,
|
||||
operationNames: allOpNames.slice(0, 15),
|
||||
clientFilters: [{ name: 'ios', versions: ['2.20.0', '2.24.0'] }],
|
||||
dateRange: { from: 'now-7d', to: 'now' },
|
||||
views: 31,
|
||||
},
|
||||
{
|
||||
name: 'Analytics Worker',
|
||||
description: 'Background analytics service operations',
|
||||
visibility: SavedFilterVisibilityType.Shared,
|
||||
operationNames: allOpNames.filter(n => n.startsWith('Analytics')),
|
||||
clientFilters: [{ name: 'analytics-pipeline-worker-v2' }],
|
||||
dateRange: { from: 'now-30d', to: 'now' },
|
||||
views: 12,
|
||||
},
|
||||
{
|
||||
name: 'Playground Exploration',
|
||||
description: 'Ad-hoc queries from GraphQL Playground',
|
||||
visibility: SavedFilterVisibilityType.Private,
|
||||
operationNames: allOpNames.slice(100, 120),
|
||||
clientFilters: [{ name: 'graphql-playground' }],
|
||||
dateRange: { from: 'now-1d', to: 'now' },
|
||||
views: 5,
|
||||
},
|
||||
{
|
||||
name: 'Droid & Human Types',
|
||||
description: 'Operations touching Droid and Human types',
|
||||
visibility: SavedFilterVisibilityType.Shared,
|
||||
operationNames: [
|
||||
...allOpNames.filter(n => n.startsWith('GetDroid')),
|
||||
...allOpNames.filter(n => n.startsWith('GetHuman')),
|
||||
],
|
||||
dateRange: { from: 'now-14d', to: 'now' },
|
||||
views: 77,
|
||||
},
|
||||
{
|
||||
name: 'Production Canary',
|
||||
description: 'Canary release monitoring — latest client versions',
|
||||
visibility: SavedFilterVisibilityType.Shared,
|
||||
operationNames: allOpNames.slice(0, 10),
|
||||
clientFilters: [
|
||||
{ name: 'web-app', versions: ['1.14.0'] },
|
||||
{ name: 'ios', versions: ['2.24.0'] },
|
||||
{ name: 'android', versions: ['3.9.0'] },
|
||||
],
|
||||
dateRange: { from: 'now-7d', to: 'now' },
|
||||
views: 203,
|
||||
},
|
||||
];
|
||||
|
||||
// Create all filters and collect results
|
||||
const createdFilters: Array<{ name: string; id: string; views: number }> = [];
|
||||
for (const def of savedFilterDefs) {
|
||||
const ops = resolveOps(def.operationNames);
|
||||
const result = await createSavedFilter({
|
||||
name: def.name,
|
||||
description: def.description,
|
||||
visibility: def.visibility,
|
||||
insightsFilter: {
|
||||
operationHashes: ops,
|
||||
...(def.clientFilters ? { clientFilters: def.clientFilters } : {}),
|
||||
...(def.dateRange ? { dateRange: def.dateRange } : {}),
|
||||
},
|
||||
});
|
||||
if (result.ok) {
|
||||
createdFilters.push({ name: def.name, id: result.ok.savedFilter.id, views: def.views });
|
||||
console.log(
|
||||
` Created: "${def.name}" (${result.ok.savedFilter.id}) — ${ops.length} ops, ${def.visibility}`,
|
||||
);
|
||||
} else {
|
||||
console.error(` Failed to create "${def.name}"`);
|
||||
}
|
||||
}
|
||||
|
||||
// Track views to populate view counts
|
||||
console.log(`👁️ Tracking views for ${createdFilters.length} filters...`);
|
||||
for (const filter of createdFilters) {
|
||||
for (let i = 0; i < filter.views; i++) {
|
||||
await trackSavedFilterView(filter.id);
|
||||
}
|
||||
console.log(` "${filter.name}": ${filter.views} views`);
|
||||
}
|
||||
|
||||
console.log(`
|
||||
✅ Seed complete!
|
||||
|
||||
Credentials:
|
||||
Email: ${ownerEmail}
|
||||
Password: ${password}
|
||||
|
||||
Navigate to:
|
||||
http://localhost:3000/${organization.slug}/${project.slug}/${target.slug}/insights
|
||||
http://localhost:3000/${organization.slug}/${project.slug}/${target.slug}/insights/manage-filters
|
||||
`);
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('Seed failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
Loading…
Reference in a new issue