Face Lifting (#2418)

This commit is contained in:
Kamil Kisiela 2023-06-20 14:01:46 +02:00 committed by GitHub
parent 27294b1923
commit a695ac297d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
111 changed files with 6223 additions and 4009 deletions

View file

@ -67,4 +67,4 @@ jobs:
--maxDepth=20 \
--maxAliasCount=20 \
--maxDirectiveCount=20 \
--maxTokenCount=800
--maxTokenCount=850

View file

@ -35,6 +35,7 @@ const config = {
scalars: {
DateTime: 'string',
SafeInt: 'number',
ID: 'string',
},
mappers: {
SchemaChangeConnection:

View file

@ -27,8 +27,14 @@ describe('basic user flow', () => {
it('should log in and log out', () => {
cy.login(user);
cy.get('header').find('button[aria-haspopup="menu"]').click();
cy.get('a[href="/logout"]').click();
cy.get('input[name="name"]').type('Bubatzbieber');
cy.get('button[type="submit"]').click();
// Logout
cy.get('[data-cy="user-menu-trigger"]').click();
cy.get('[data-cy="user-menu-logout"]').click();
cy.url().should('include', '/auth?redirectToPath=%2F');
});
});
@ -38,7 +44,7 @@ it('create organization', () => {
cy.signup(user);
cy.get('input[name="name"]').type('Bubatzbieber');
cy.get('button[type="submit"]').click();
cy.get('h1').contains('Bubatzbieber');
cy.get('[data-cy="organization-picker-current"]').contains('Bubatzbieber');
});
it('oidc login for organization', () => {
@ -47,7 +53,7 @@ it('oidc login for organization', () => {
cy.signup(organizationAdminUser);
cy.get('input[name="name"]').type('Bubatzbieber');
cy.get('button[type="submit"]').click();
cy.get('h1').contains('Bubatzbieber');
cy.get('[data-cy="organization-picker-current"]').contains('Bubatzbieber');
cy.get('a[href$="/view/settings"]').click();
cy.get('a[href$="/view/settings#create-oidc-integration"]').click();
cy.get('input[id="tokenEndpoint"]').type('http://oidc-server-mock:80/connect/token');
@ -73,6 +79,6 @@ it('oidc login for organization', () => {
cy.get('input[id="Input_Password"]').type('password');
cy.get('button[value="login"]').click();
cy.get('h1').contains('Bubatzbieber');
cy.get('[data-cy="organization-picker-current"]').contains('Bubatzbieber');
});
});

View file

@ -58,7 +58,7 @@ export function deployCloudFlareSecurityTransform(options: {
connect-src 'self' {DYNAMIC_HOST_PLACEHOLDER} ${cspHosts};
media-src ${crispHost};
style-src-elem 'self' 'unsafe-inline' ${monacoCdnDynamicBasePath} ${monacoCdnStaticBasePath} fonts.googleapis.com ${crispHost};
font-src 'self' fonts.gstatic.com ${monacoCdnDynamicBasePath} ${monacoCdnStaticBasePath} ${crispHost};
font-src 'self' fonts.gstatic.com rsms.me ${monacoCdnDynamicBasePath} ${monacoCdnStaticBasePath} ${crispHost};
img-src * 'self' data: https: https://image.crisp.chat https://storage.crisp.chat ${gtmHost} ${crispHost};
`;

View file

@ -51,14 +51,14 @@
"devDependencies": {
"@changesets/changelog-github": "0.4.8",
"@changesets/cli": "2.26.1",
"@graphql-codegen/add": "4.0.1",
"@graphql-codegen/cli": "3.3.1",
"@graphql-codegen/client-preset": "3.0.1",
"@graphql-codegen/graphql-modules-preset": "3.1.3",
"@graphql-codegen/typed-document-node": "4.0.1",
"@graphql-codegen/typescript": "3.0.4",
"@graphql-codegen/typescript-operations": "3.0.4",
"@graphql-codegen/typescript-resolvers": "3.2.1",
"@graphql-codegen/add": "5.0.0",
"@graphql-codegen/cli": "4.0.1",
"@graphql-codegen/client-preset": "4.0.0",
"@graphql-codegen/graphql-modules-preset": "4.0.0",
"@graphql-codegen/typed-document-node": "5.0.0",
"@graphql-codegen/typescript": "4.0.0",
"@graphql-codegen/typescript-operations": "4.0.0",
"@graphql-codegen/typescript-resolvers": "4.0.0",
"@graphql-inspector/cli": "3.4.19",
"@manypkg/get-packages": "2.2.0",
"@next/eslint-plugin-next": "13.3.1",

View file

@ -3,14 +3,14 @@ import { gql } from 'graphql-modules';
export default gql`
extend type Mutation {
addAlertChannel(input: AddAlertChannelInput!): AddAlertChannelResult!
deleteAlertChannels(input: DeleteAlertChannelsInput!): [AlertChannel!]!
addAlert(input: AddAlertInput!): Alert!
deleteAlerts(input: DeleteAlertsInput!): [Alert!]!
deleteAlertChannels(input: DeleteAlertChannelsInput!): DeleteAlertChannelsResult!
addAlert(input: AddAlertInput!): AddAlertResult!
deleteAlerts(input: DeleteAlertsInput!): DeleteAlertsResult!
}
extend type Query {
alertChannels(selector: ProjectSelectorInput!): [AlertChannel!]!
alerts(selector: ProjectSelectorInput!): [Alert!]!
extend type Project {
alertChannels: [AlertChannel!]!
alerts: [Alert!]!
}
enum AlertChannelType {
@ -22,12 +22,53 @@ export default gql`
SCHEMA_CHANGE_NOTIFICATIONS
}
type DeleteAlertChannelsResult {
ok: DeleteAlertChannelsOk
error: DeleteAlertChannelsError
}
type DeleteAlertChannelsOk {
updatedProject: Project!
}
type DeleteAlertChannelsError implements Error {
message: String!
}
type AddAlertResult {
ok: AddAlertOk
error: AddAlertError
}
type AddAlertOk {
updatedProject: Project!
addedAlert: Alert!
}
type AddAlertError implements Error {
message: String!
}
type DeleteAlertsResult {
ok: DeleteAlertsOk
error: DeleteAlertsError
}
type DeleteAlertsOk {
updatedProject: Project!
}
type DeleteAlertsError implements Error {
message: String!
}
type AddAlertChannelResult {
ok: AddAlertChannelOk
error: AddAlertChannelError
}
type AddAlertChannelOk {
updatedProject: Project!
addedAlertChannel: AlertChannel!
}

View file

@ -1,4 +1,6 @@
import { z } from 'zod';
import { HiveError } from '../../shared/errors';
import { ProjectManager } from '../project/providers/project-manager';
import { IdTranslator } from '../shared/providers/id-translator';
import { TargetManager } from '../target/providers/target-manager';
import type { AlertsModule } from './__generated__/types';
@ -33,16 +35,20 @@ export const resolvers: AlertsModule.Resolvers = {
}
const translator = injector.get(IdTranslator);
const [organization, project] = await Promise.all([
const [organizationId, projectId] = await Promise.all([
translator.translateOrganizationId(input),
translator.translateProjectId(input),
]);
return {
ok: {
updatedProject: injector.get(ProjectManager).getProject({
organization: organizationId,
project: projectId,
}),
addedAlertChannel: injector.get(AlertsManager).addChannel({
organization,
project,
organization: organizationId,
project: projectId,
name: input.name,
type: input.type,
slack: input.slack,
@ -53,70 +59,128 @@ export const resolvers: AlertsModule.Resolvers = {
},
async deleteAlertChannels(_, { input }, { injector }) {
const translator = injector.get(IdTranslator);
const [organization, project] = await Promise.all([
const [organizationId, projectId] = await Promise.all([
translator.translateOrganizationId(input),
translator.translateProjectId(input),
]);
return injector.get(AlertsManager).deleteChannels({
organization,
project,
channels: input.channels,
const project = await injector.get(ProjectManager).getProject({
organization: organizationId,
project: projectId,
});
try {
await injector.get(AlertsManager).deleteChannels({
organization: organizationId,
project: projectId,
channels: input.channels,
});
return {
ok: {
updatedProject: project,
},
};
} catch (error) {
if (error instanceof HiveError) {
return {
error: {
message: error.message,
},
};
}
throw error;
}
},
async addAlert(_, { input }, { injector }) {
const translator = injector.get(IdTranslator);
const [organization, project, target] = await Promise.all([
const [organizationId, projectId, targetId] = await Promise.all([
translator.translateOrganizationId(input),
translator.translateProjectId(input),
translator.translateTargetId(input),
]);
return injector.get(AlertsManager).addAlert({
organization,
project,
target,
channel: input.channel,
type: input.type,
const project = await injector.get(ProjectManager).getProject({
organization: organizationId,
project: projectId,
});
try {
const alert = await injector.get(AlertsManager).addAlert({
organization: organizationId,
project: projectId,
target: targetId,
channel: input.channel,
type: input.type,
});
return {
ok: {
addedAlert: alert,
updatedProject: project,
},
};
} catch (error) {
if (error instanceof HiveError) {
return {
error: {
message: error.message,
},
};
}
throw error;
}
},
async deleteAlerts(_, { input }, { injector }) {
const translator = injector.get(IdTranslator);
const [organization, project] = await Promise.all([
const [organizationId, projectId] = await Promise.all([
translator.translateOrganizationId(input),
translator.translateProjectId(input),
]);
return injector.get(AlertsManager).deleteAlerts({
organization,
project,
alerts: input.alerts,
const project = await injector.get(ProjectManager).getProject({
organization: organizationId,
project: projectId,
});
try {
await injector.get(AlertsManager).deleteAlerts({
organization: organizationId,
project: projectId,
alerts: input.alerts,
});
return {
ok: {
updatedProject: project,
},
};
} catch (error) {
if (error instanceof HiveError) {
return {
error: {
message: error.message,
},
};
}
throw error;
}
},
},
Query: {
async alerts(_, { selector }, { injector }) {
const translator = injector.get(IdTranslator);
const [organization, project] = await Promise.all([
translator.translateOrganizationId(selector),
translator.translateProjectId(selector),
]);
Project: {
async alerts(project, _, { injector }) {
return injector.get(AlertsManager).getAlerts({
organization,
project,
organization: project.orgId,
project: project.id,
});
},
async alertChannels(_, { selector }, { injector }) {
const translator = injector.get(IdTranslator);
const [organization, project] = await Promise.all([
translator.translateOrganizationId(selector),
translator.translateProjectId(selector),
]);
async alertChannels(project, _, { injector }) {
return injector.get(AlertsManager).getChannels({
organization,
project,
organization: project.orgId,
project: project.id,
});
},
},

View file

@ -1,13 +1,8 @@
import { Injectable, Scope } from 'graphql-modules';
import {
CreateDocumentCollectionInput,
CreateDocumentCollectionOperationInput,
UpdateDocumentCollectionInput,
UpdateDocumentCollectionOperationInput,
} from '@/graphql';
import { AuthManager } from '../../auth/providers/auth-manager';
import { Logger } from '../../shared/providers/logger';
import { Storage } from '../../shared/providers/storage';
import type { CollectionModule } from './../__generated__/types';
@Injectable({
global: true,
@ -46,7 +41,10 @@ export class CollectionProvider {
async createCollection(
targetId: string,
{ name, description }: Pick<CreateDocumentCollectionInput, 'description' | 'name'>,
{
name,
description,
}: Pick<CollectionModule.CreateDocumentCollectionInput, 'description' | 'name'>,
) {
const currentUser = await this.authManager.getCurrentUser();
@ -62,7 +60,7 @@ export class CollectionProvider {
return this.storage.deleteDocumentCollection({ documentCollectionId: id });
}
async createOperation(input: CreateDocumentCollectionOperationInput) {
async createOperation(input: CollectionModule.CreateDocumentCollectionOperationInput) {
const currentUser = await this.authManager.getCurrentUser();
return this.storage.createDocumentCollectionDocument({
@ -75,7 +73,7 @@ export class CollectionProvider {
});
}
updateOperation(input: UpdateDocumentCollectionOperationInput) {
updateOperation(input: CollectionModule.UpdateDocumentCollectionOperationInput) {
return this.storage.updateDocumentCollectionDocument({
documentCollectionDocumentId: input.operationId,
title: input.name,
@ -85,7 +83,7 @@ export class CollectionProvider {
});
}
updateCollection(input: UpdateDocumentCollectionInput) {
updateCollection(input: CollectionModule.UpdateDocumentCollectionInput) {
return this.storage.updateDocumentCollection({
documentCollectionId: input.collectionId,
description: input.description || null,

View file

@ -164,4 +164,12 @@ export default gql`
extend type OrganizationGetStarted {
reportingOperations: Boolean!
}
extend type Target {
requestsOverTime(resolution: Int!, period: DateRangeInput!): [RequestsOverTime!]!
}
extend type Project {
requestsOverTime(resolution: Int!, period: DateRangeInput!): [RequestsOverTime!]!
}
`;

View file

@ -74,7 +74,10 @@ export class ClickHouse {
const startedAt = Date.now();
const endpoint = `${this.config.protocol ?? 'https'}://${this.config.host}:${this.config.port}`;
this.logger.debug(`Executing ClickHouse Query: %s`, query);
this.logger.debug(
`Executing ClickHouse Query: %s`,
query.sql.replace(/\n/g, ' ').replace(/\s+/g, ' '),
);
const response = await this.httpClient
.post<QueryResponse<T>>(

View file

@ -7,7 +7,11 @@ import { cache } from '../../../shared/helpers';
import { AuthManager } from '../../auth/providers/auth-manager';
import { TargetAccessScope } from '../../auth/providers/target-access';
import { Logger } from '../../shared/providers/logger';
import type { OrganizationSelector, TargetSelector } from '../../shared/providers/storage';
import type {
OrganizationSelector,
ProjectSelector,
TargetSelector,
} from '../../shared/providers/storage';
import { Storage } from '../../shared/providers/storage';
import { OperationsReader } from './operations-reader';
@ -58,6 +62,20 @@ interface ReadFieldStatsOutput {
})
export class OperationsManager {
private logger: Logger;
private requestsOverTimeOfTargetsLoader: DataLoader<
{
targets: readonly string[];
period: DateRange;
resolution: number;
},
{
[target: string]: {
date: any;
value: number;
}[];
},
string
>;
constructor(
logger: Logger,
@ -66,6 +84,22 @@ export class OperationsManager {
private storage: Storage,
) {
this.logger = logger.child({ source: 'OperationsManager' });
this.requestsOverTimeOfTargetsLoader = new DataLoader(
async selectors => {
const results = await this.reader.requestsOverTimeOfTargets(selectors);
return results;
},
{
cacheKeyFn(selector) {
return `${selector.period.from.toISOString()};${selector.period.to.toISOString()};${
selector.resolution
};${selector.targets.join(',')}}`;
},
batchScheduleFn: callback => setTimeout(callback, 100),
},
);
}
async getOperationBody({
@ -299,6 +333,109 @@ export class OperationsManager {
});
}
async readRequestsOverTimeOfProject({
period,
resolution,
organization,
project,
}: {
period: DateRange;
resolution: number;
} & ProjectSelector): Promise<
Array<{
date: any;
value: number;
}>
> {
this.logger.debug(
'Reading requests over time of project (period=%o, resolution=%s, project=%s)',
period,
resolution,
project,
);
const targets = await this.storage.getTargetIdsOfProject({
organization,
project,
});
await Promise.all(
targets.map(target =>
this.authManager.ensureTargetAccess({
organization,
project,
target,
scope: TargetAccessScope.REGISTRY_READ,
}),
),
);
const groups = await this.requestsOverTimeOfTargetsLoader.load({
targets,
period,
resolution,
});
// Because we get data for each target separately, we need to sum(targets) per date
// All dates are the same for all targets as we use `toStartOfInterval` function of clickhouse under the hood,
// with the same interval value.
// The `toStartOfInterval` function gives back the same output for data time points within the same interval window.
// Let's say that interval is 10 minutes.
// `2023-21-10 21:37` and `2023-21-10 21:38` are within 21:30 and 21:40 window, so the output will be `2023-21-10 21:30`.
const dataPointsAggregator = new Map<number, number>();
for (const target in groups) {
const targetDataPoints = groups[target];
for (const dataPoint of targetDataPoints) {
const existing = dataPointsAggregator.get(dataPoint.date);
if (existing == null) {
dataPointsAggregator.set(dataPoint.date, dataPoint.value);
} else {
dataPointsAggregator.set(dataPoint.date, existing + dataPoint.value);
}
}
}
return Array.from(dataPointsAggregator.entries())
.map(([date, value]) => ({ date, value }))
.sort((a, b) => a.date - b.date);
}
async readRequestsOverTimeOfTargets({
period,
resolution,
organization,
project,
targets,
}: {
period: DateRange;
resolution: number;
targets: string[];
} & ProjectSelector) {
this.logger.debug(
'Reading requests over time of targets (period=%o, resolution=%s, targets=%s)',
period,
resolution,
targets.join(';'),
);
await Promise.all(
targets.map(target =>
this.authManager.ensureTargetAccess({
organization,
project,
target,
scope: TargetAccessScope.REGISTRY_READ,
}),
),
);
return this.requestsOverTimeOfTargetsLoader.load({
targets,
period,
resolution,
});
}
async readRequestsOverTime({
period,
resolution,

View file

@ -834,6 +834,188 @@ export class OperationsReader {
});
}
@sentry('OperationsReader.requestsOverTimeOfTargets')
async requestsOverTimeOfTargets(
selectors: ReadonlyArray<{
targets: readonly string[];
period: DateRange;
resolution: number;
}>,
) {
const aggregationMap = new Map<
string,
{
targets: string[];
period: DateRange;
resolution: number;
}
>();
const makeKey = (selector: { period: DateRange; resolution: number }) =>
`${selector.period.from.toISOString()};${selector.period.to.toISOString()};${
selector.resolution
}`;
const subSelectors = selectors
.map(selector =>
selector.targets.map(target => ({
target,
period: selector.period,
resolution: selector.resolution,
})),
)
.flat(1);
// The idea here is to make the least possible number of queries to ClickHouse
// by fetching all data points of the same target, period and resolution.
for (const selector of subSelectors) {
const key = makeKey(selector);
const value = aggregationMap.get(key);
if (!value) {
aggregationMap.set(key, {
targets: [selector.target],
period: selector.period,
resolution: selector.resolution,
});
} else {
value.targets.push(selector.target);
}
}
const aggregationResultMap = new Map<
string,
Promise<
readonly {
date: number;
total: number;
target: string;
}[]
>
>();
for (const [key, { targets, period, resolution }] of aggregationMap) {
aggregationResultMap.set(
key,
this.clickHouse
.query<{
date: number;
target: string;
total: number;
}>(
pickQueryByPeriod(
{
daily: {
query: sql`
SELECT
multiply(
toUnixTimestamp(
toStartOfInterval(timestamp, INTERVAL ${this.clickHouse.translateWindow(
calculateTimeWindow({ period, resolution }),
)}, 'UTC'),
'UTC'),
1000) as date,
sum(total) as total,
target
FROM operations_daily
${this.createFilter({ target: targets, period })}
GROUP BY date, target
ORDER BY date
`,
queryId: 'targets_count_over_time_daily',
timeout: 15_000,
},
hourly: {
query: sql`
SELECT
multiply(
toUnixTimestamp(
toStartOfInterval(timestamp, INTERVAL ${this.clickHouse.translateWindow(
calculateTimeWindow({ period, resolution }),
)}, 'UTC'),
'UTC'),
1000) as date,
sum(total) as total,
target
FROM operations_hourly
${this.createFilter({ target: targets, period })}
GROUP BY date, target
ORDER BY date
`,
queryId: 'targets_count_over_time_hourly',
timeout: 15_000,
},
regular: {
query: sql`
SELECT
multiply(
toUnixTimestamp(
toStartOfInterval(timestamp, INTERVAL ${this.clickHouse.translateWindow(
calculateTimeWindow({ period, resolution }),
)}, 'UTC'),
'UTC'),
1000) as date,
count(*) as total,
target
FROM operations
${this.createFilter({ target: targets, period })}
GROUP BY date, target
ORDER BY date
`,
queryId: 'targets_count_over_time_regular',
timeout: 15_000,
},
},
period,
resolution,
),
)
.then(result => result.data),
);
}
// Because the function is used in a DataLoader,
// it has to return a list of promises matching the order of selectors.
return Promise.all(
selectors.map(async selector => {
const key = makeKey(selector);
const queryPromise = aggregationResultMap.get(key);
if (!queryPromise) {
throw new Error(`Could not find data for ${key} selector`);
}
const rows = await queryPromise;
const resultsPerTarget: {
[target: string]: Array<{
date: any;
value: number;
}>;
} = {};
for (const row of rows) {
if (!selector.targets.includes(row.target)) {
// it's not relevant to the current selector
continue;
}
if (!resultsPerTarget[row.target]) {
resultsPerTarget[row.target] = [];
}
resultsPerTarget[row.target].push({
date: ensureNumber(row.date) as any,
value: ensureNumber(row.total),
});
}
return resultsPerTarget;
}),
);
}
@sentry('OperationsReader.requestsOverTime')
async requestsOverTime({
target,
@ -1261,7 +1443,9 @@ export class OperationsReader {
>();
const makeKey = (selector: { target: string; period: DateRange }) =>
`${selector.target}-${selector.period.from}-${selector.period.to}`;
`${
selector.target
}-${selector.period.from.toISOString()}-${selector.period.to.toISOString()}`;
// Groups the type names by their target and period
// The idea here is to make the least possible number of queries to ClickHouse

View file

@ -329,6 +329,29 @@ export const resolvers: OperationsModule.Resolvers = {
});
},
},
Project: {
async requestsOverTime(project, { resolution, period }, { injector }) {
return injector.get(OperationsManager).readRequestsOverTimeOfProject({
project: project.id,
organization: project.orgId,
period: parseDateRangeInput(period),
resolution,
});
},
},
Target: {
async requestsOverTime(target, { resolution, period }, { injector }) {
const result = await injector.get(OperationsManager).readRequestsOverTimeOfTargets({
project: target.projectId,
organization: target.orgId,
targets: [target.id],
period: parseDateRangeInput(period),
resolution,
});
return result[target.id] ?? [];
},
},
};
function transformPercentile(value: number | null): number {

View file

@ -48,9 +48,9 @@ export default gql`
error: CreateProjectError
}
type CreateProjectOk {
selector: ProjectSelector!
createdProject: Project!
createdTargets: [Target!]!
updatedOrganization: Organization!
}
type CreateProjectInputErrors {

View file

@ -1,6 +1,7 @@
import { z } from 'zod';
import { ProjectType } from '../../shared/entities';
import { createConnection } from '../../shared/schema';
import { OrganizationManager } from '../organization/providers/organization-manager';
import { IdTranslator } from '../shared/providers/id-translator';
import { TargetManager } from '../target/providers/target-manager';
import type { ProjectModule } from './__generated__/types';
@ -55,12 +56,15 @@ export const resolvers: ProjectModule.Resolvers & { ProjectType: any } = {
}
const translator = injector.get(IdTranslator);
const organization = await translator.translateOrganizationId({
const organizationId = await translator.translateOrganizationId({
organization: input.organization,
});
const project = await injector.get(ProjectManager).createProject({
...input,
organization,
organization: organizationId,
});
const organization = await injector.get(OrganizationManager).getOrganization({
organization: organizationId,
});
const targetManager = injector.get(TargetManager);
@ -69,28 +73,25 @@ export const resolvers: ProjectModule.Resolvers & { ProjectType: any } = {
targetManager.createTarget({
name: 'production',
project: project.id,
organization,
organization: organizationId,
}),
targetManager.createTarget({
name: 'staging',
project: project.id,
organization,
organization: organizationId,
}),
targetManager.createTarget({
name: 'development',
project: project.id,
organization,
organization: organizationId,
}),
]);
return {
ok: {
selector: {
organization: input.organization,
project: project.cleanId,
},
createdProject: project,
createdTargets: targets,
updatedOrganization: organization,
},
};
},

View file

@ -122,6 +122,11 @@ export default gql`
extend type Project {
externalSchemaComposition: ExternalSchemaComposition
registryModel: RegistryModel!
schemaVersionsCount(period: DateRangeInput): Int!
}
extend type Target {
schemaVersionsCount(period: DateRangeInput): Int!
}
type EnableExternalSchemaCompositionError implements Error {

View file

@ -5,7 +5,7 @@ import { z } from 'zod';
import { Change } from '@graphql-inspector/core';
import type { SchemaCheck, SchemaCompositionError } from '@hive/storage';
import { RegistryModel } from '../../../__generated__/types';
import { Orchestrator, ProjectType } from '../../../shared/entities';
import { DateRange, Orchestrator, ProjectType } from '../../../shared/entities';
import { HiveError } from '../../../shared/errors';
import { atomic, stringifySelector } from '../../../shared/helpers';
import { SchemaVersion } from '../../../shared/mappers';
@ -438,6 +438,24 @@ export class SchemaManager {
await this.storage.updateBaseSchema(selector, newBaseSchema);
}
countSchemaVersionsOfProject(
selector: ProjectSelector & {
period: DateRange | null;
},
): Promise<number> {
this.logger.debug('Fetching schema versions count of project (selector=%o)', selector);
return this.storage.countSchemaVersionsOfProject(selector);
}
countSchemaVersionsOfTarget(
selector: TargetSelector & {
period: DateRange | null;
},
): Promise<number> {
this.logger.debug('Fetching schema versions count of target (selector=%o)', selector);
return this.storage.countSchemaVersionsOfTarget(selector);
}
completeGetStartedCheck(
selector: OrganizationSelector & {
step: 'publishingSchema' | 'checkingSchema';

View file

@ -671,6 +671,14 @@ export const resolvers: SchemaModule.Resolvers = {
pageInfo: result.pageInfo,
};
},
schemaVersionsCount(target, { period }, { injector }) {
return injector.get(SchemaManager).countSchemaVersionsOfTarget({
organization: target.orgId,
project: target.projectId,
target: target.id,
period: period ? parseDateRangeInput(period) : null,
});
},
},
SchemaVersion: {
async log(version, _, { injector }) {
@ -1010,6 +1018,13 @@ export const resolvers: SchemaModule.Resolvers = {
registryModel(project) {
return project.legacyRegistryModel ? 'LEGACY' : 'MODERN';
},
schemaVersionsCount(project, { period }, { injector }) {
return injector.get(SchemaManager).countSchemaVersionsOfProject({
organization: project.orgId,
project: project.id,
period: period ? parseDateRangeInput(period) : null,
});
},
},
SchemaExplorer: {
async type(source, { name }, { injector }) {

View file

@ -265,7 +265,7 @@ export interface Storage {
getTargets(_: ProjectSelector): Promise<readonly Target[]>;
getTargetIdsOfOrganization(_: OrganizationSelector): Promise<readonly string[]>;
getTargetIdsOfProject(_: ProjectSelector): Promise<readonly string[]>;
getTargetSettings(_: TargetSelector): Promise<TargetSettings | never>;
setTargetValidation(
@ -276,6 +276,23 @@ export interface Storage {
_: TargetSelector & Omit<TargetSettings['validation'], 'enabled'>,
): Promise<TargetSettings['validation'] | never>;
countSchemaVersionsOfProject(
_: ProjectSelector & {
period: {
from: Date;
to: Date;
} | null;
},
): Promise<number>;
countSchemaVersionsOfTarget(
_: TargetSelector & {
period: {
from: Date;
to: Date;
} | null;
},
): Promise<number>;
hasSchema(_: TargetSelector): Promise<boolean>;
getLatestSchemas(

View file

@ -1460,6 +1460,15 @@ export async function createStorage(connection: string, maximumPoolSize: number)
return results.rows.map(r => r.id);
},
async getTargetIdsOfProject({ project }) {
const results = await pool.query<Slonik<Pick<targets, 'id'>>>(
sql`
SELECT id FROM public.targets WHERE project_id = ${project}
`,
);
return results.rows.map(r => r.id);
},
async getTargetSettings({ target, project }) {
const row = await pool.one<
Pick<
@ -1582,6 +1591,47 @@ export async function createStorage(connection: string, maximumPoolSize: number)
}),
).validation;
},
async countSchemaVersionsOfProject({ project, period }) {
if (period) {
const result = await pool.maybeOne<{ total: number }>(sql`
SELECT COUNT(*) as total FROM public.schema_versions as sv
LEFT JOIN public.targets as t ON (t.id = sv.target_id)
WHERE
t.project_id = ${project}
AND sv.created_at >= ${period.from.toISOString()}
AND sv.created_at < ${period.to.toISOString()}
`);
return result?.total ?? 0;
}
const result = await pool.maybeOne<{ total: number }>(sql`
SELECT COUNT(*) as total FROM public.schema_versions as sv
LEFT JOIN public.targets as t ON (t.id = sv.target_id)
WHERE t.project_id = ${project}
`);
return result?.total ?? 0;
},
async countSchemaVersionsOfTarget({ target, period }) {
if (period) {
const result = await pool.maybeOne<{ total: number }>(sql`
SELECT COUNT(*) as total FROM public.schema_versions
WHERE
target_id = ${target}
AND created_at >= ${period.from.toISOString()}
AND created_at < ${period.to.toISOString()}
`);
return result?.total ?? 0;
}
const result = await pool.maybeOne<{ total: number }>(sql`
SELECT COUNT(*) as total FROM public.schema_versions WHERE target_id = ${target}
`);
return result?.total ?? 0;
},
async hasSchema({ target }) {
return pool.exists(
sql`

View file

@ -30,6 +30,7 @@
"@radix-ui/react-radio-group": "1.1.3",
"@radix-ui/react-select": "1.2.2",
"@radix-ui/react-slider": "1.1.2",
"@radix-ui/react-slot": "1.0.2",
"@radix-ui/react-switch": "1.0.3",
"@radix-ui/react-tabs": "1.0.4",
"@radix-ui/react-toggle-group": "1.0.4",
@ -47,6 +48,7 @@
"@urql/devtools": "2.0.3",
"@urql/exchange-graphcache": "6.1.4",
"@whatwg-node/fetch": "0.9.6",
"class-variance-authority": "0.6.0",
"clsx": "1.2.1",
"cookies": "0.8.0",
"date-fns": "2.30.0",
@ -61,6 +63,7 @@
"js-cookie": "3.0.5",
"json-schema-typed": "8.0.1",
"json-schema-yup-transformer": "1.6.12",
"lucide-react": "0.236.0",
"monaco-editor": "0.39.0",
"monaco-themes": "0.4.4",
"next": "13.3.1",
@ -70,6 +73,7 @@
"react-date-range": "1.4.0",
"react-dom": "18.2.0",
"react-highlight-words": "0.20.0",
"react-icons": "4.9.0",
"react-select": "5.7.3",
"react-string-replace": "1.1.0",
"react-toastify": "9.1.3",
@ -81,6 +85,7 @@
"supertokens-js-override": "0.0.4",
"supertokens-node": "13.4.2",
"supertokens-web-js": "0.5.0",
"tailwind-merge": "1.13.1",
"tslib": "2.5.3",
"urql": "4.0.4",
"use-debounce": "9.0.4",
@ -118,6 +123,7 @@
"rimraf": "4.4.1",
"storybook": "7.0.22",
"tailwindcss": "3.3.2",
"tailwindcss-animate": "1.0.6",
"tailwindcss-radix": "2.8.0"
},
"buildOptions": {

View file

@ -1,12 +1,11 @@
import Image from 'next/image';
import Router from 'next/router';
import { Button, Header } from '@/components/v2';
import { Button } from '@/components/v2';
import ghost from '../public/images/figures/ghost.svg';
const NotFoundPage = () => {
return (
<>
<Header />
<div className="flex h-screen flex-col items-center justify-center gap-2.5">
<Image
src={ghost}

View file

@ -1,39 +1,31 @@
import { useMemo, useState } from 'react';
import NextLink from 'next/link';
import clsx from 'clsx';
import { useQuery } from 'urql';
import { authenticated } from '@/components/authenticated-container';
import { TargetLayout } from '@/components/layouts';
import { TargetLayout } from '@/components/layouts/target';
import { SchemaEditor } from '@/components/schema-editor';
import { ChangesBlock, labelize } from '@/components/target/history/errors-and-changes';
import { Badge, Button, DiffEditor, Heading, TimeAgo, Title } from '@/components/v2';
import { Subtitle, Title } from '@/components/ui/page';
import {
Badge,
Button,
DiffEditor,
DocsLink,
EmptyList,
Heading,
MetaTitle,
TimeAgo,
} from '@/components/v2';
import { AlertTriangleIcon, DiffIcon } from '@/components/v2/icon';
import { FragmentType, graphql, useFragment } from '@/gql';
import { CriticalityLevel } from '@/gql/graphql';
import { useRouteSelector } from '@/lib/hooks';
import { useNotFoundRedirectOnError } from '@/lib/hooks/use-not-found-redirect-on-error';
import { withSessionProtection } from '@/lib/supertokens/guard';
import { cn } from '@/lib/utils';
import { ListBulletIcon } from '@radix-ui/react-icons';
import * as ToggleGroup from '@radix-ui/react-toggle-group';
const ChecksPageQuery = graphql(`
query ChecksPageQuery($organizationId: ID!, $projectId: ID!, $targetId: ID!) {
target(selector: { organization: $organizationId, project: $projectId, target: $targetId }) {
id
}
organization(selector: { organization: $organizationId }) {
organization {
...TargetLayout_OrganizationFragment
}
}
project(selector: { organization: $organizationId, project: $projectId }) {
...TargetLayout_ProjectFragment
}
targets(selector: { organization: $organizationId, project: $projectId }) {
...TargetLayout_TargetConnectionFragment
}
}
`);
const SchemaChecks_NavigationQuery = graphql(`
query SchemaChecks_NavigationQuery(
$organizationId: ID!
@ -88,7 +80,7 @@ const Navigation = (props: {
<>
{query.data.target.schemaChecks.edges.map(edge => (
<div
className={clsx(
className={cn(
'flex flex-col rounded-md p-2.5 hover:bg-gray-800/40',
edge.node.id === router.schemaCheckId ? 'bg-gray-800/40' : null,
)}
@ -106,7 +98,7 @@ const Navigation = (props: {
) : null}
<div className="mt-2.5 mb-1.5 flex align-middle text-xs font-medium text-[#c4c4c4]">
<div
className={clsx(
className={cn(
'w-1/2 ',
edge.node.__typename === 'FailedSchemaCheck' ? 'text-red-500' : null,
)}
@ -243,7 +235,7 @@ const PolicyBlock = (props: {
{policies.edges.map((edge, key) => (
<li
key={key}
className={clsx(props.type === 'warning' ? 'text-yellow-400' : 'text-red-400')}
className={cn(props.type === 'warning' ? 'text-yellow-400' : 'text-red-400')}
>
<span className="text-gray-600 dark:text-white">{labelize(edge.node.message)}</span>
</li>
@ -253,7 +245,11 @@ const PolicyBlock = (props: {
);
};
const ActiveSchemaCheck = (): React.ReactElement | null => {
const ActiveSchemaCheck = ({
schemaCheckId,
}: {
schemaCheckId: string | null;
}): React.ReactElement | null => {
const router = useRouteSelector();
const [query] = useQuery({
query: ActiveSchemaCheckQuery,
@ -261,9 +257,9 @@ const ActiveSchemaCheck = (): React.ReactElement | null => {
organizationId: router.organizationId,
projectId: router.projectId,
targetId: router.targetId,
schemaCheckId: router.schemaCheckId ?? '',
schemaCheckId: schemaCheckId ?? '',
},
pause: !router.schemaCheckId,
pause: !schemaCheckId,
});
const [view, setView] = useState<string>('details');
@ -333,18 +329,26 @@ const ActiveSchemaCheck = (): React.ReactElement | null => {
}, [query.data?.target?.schemaCheck]);
if (!query.data?.target?.schemaCheck) {
return null;
return (
<EmptyList
className="border-0"
title="Check not found"
description="Learn how to check your schema with Hive CLI"
docsUrl="/features/schema-registry#check-a-schema"
/>
);
}
const { schemaCheck } = query.data.target;
return (
<div className="flex grow flex-col">
<div className="flex items-center justify-between mb-4">
<Heading>Check {schemaCheck.id}</Heading>
<div className="flex grow flex-col h-full">
<div className="py-6">
<Title>Check {schemaCheck.id}</Title>
<Subtitle>Detailed view of the schema check</Subtitle>
</div>
<div className="flex flex-col gap-2 mb-1">
<div className="mb-1.5 flex align-middle font-medium p-2 text-[#c4c4c4] rounded-md border-gray-800 border space-x-4">
<div>
<div className="flex align-middle font-medium p-2 text-[#c4c4c4] rounded-md border-gray-800 border space-x-4">
<div>
<div className="text-xs">
Triggered <TimeAgo date={schemaCheck.createdAt} />
@ -378,28 +382,30 @@ const ActiveSchemaCheck = (): React.ReactElement | null => {
</div>
</div>
<ToggleGroup.Root
className="flex space-x-1 rounded-md bg-gray-900/50 text-gray-500 p-0.5 mb-2"
type="single"
defaultValue={view}
onValueChange={value => value && setView(value)}
orientation="vertical"
>
{toggleItems.map(item => (
<ToggleGroup.Item
key={item.value}
value={item.value}
className={clsx(
'flex items-center rounded-md py-[0.4375rem] px-2 text-xs font-semibold hover:text-white',
view === item.value && 'bg-gray-800 text-white',
)}
title={item.tooltip}
>
{item.icon}
<span className="ml-2">{item.label}</span>
</ToggleGroup.Item>
))}
</ToggleGroup.Root>
<div className="pt-6 pb-1">
<ToggleGroup.Root
className="flex space-x-1 rounded-md bg-gray-900/50 text-gray-500"
type="single"
defaultValue={view}
onValueChange={value => value && setView(value)}
orientation="vertical"
>
{toggleItems.map(item => (
<ToggleGroup.Item
key={item.value}
value={item.value}
className={cn(
'flex items-center rounded-md py-[0.4375rem] px-2 text-xs font-semibold hover:text-white',
view === item.value && 'bg-gray-800 text-white',
)}
title={item.tooltip}
>
{item.icon}
<span className="ml-2">{item.label}</span>
</ToggleGroup.Item>
))}
</ToggleGroup.Root>
</div>
{view === 'details' ? (
<>
<>
@ -564,25 +570,90 @@ const SchemaPolicyEditor = (props: {
);
};
function ChecksPage() {
const router = useRouteSelector();
const ChecksPageQuery = graphql(`
query ChecksPageQuery($organizationId: ID!, $projectId: ID!, $targetId: ID!) {
organizations {
...TargetLayout_OrganizationConnectionFragment
}
organization(selector: { organization: $organizationId }) {
organization {
...TargetLayout_CurrentOrganizationFragment
}
}
project(selector: { organization: $organizationId, project: $projectId }) {
...TargetLayout_CurrentProjectFragment
}
target(selector: { organization: $organizationId, project: $projectId, target: $targetId }) {
id
schemaChecks(first: 1) {
edges {
node {
id
}
}
}
}
me {
...TargetLayout_MeFragment
}
...TargetLayout_IsCDNEnabledFragment
}
`);
function ChecksPageContent() {
const [paginationVariables, setPaginationVariables] = useState<Array<string | null>>(() => [
null,
]);
const router = useRouteSelector();
const [query] = useQuery({
query: ChecksPageQuery,
variables: {
organizationId: router.organizationId,
projectId: router.projectId,
targetId: router.targetId,
},
});
useNotFoundRedirectOnError(!!query.error);
if (query.error) {
return null;
}
const me = query.data?.me;
const currentOrganization = query.data?.organization?.organization;
const currentProject = query.data?.project;
const organizationConnection = query.data?.organizations;
const isCDNEnabled = query.data;
const { schemaCheckId } = router;
const hasSchemaChecks = !!query.data?.target?.schemaChecks?.edges?.length;
const hasActiveSchemaCheck = !!schemaCheckId;
return (
<>
<Title title="Schema Checks" />
<TargetLayout
value="checks"
className="flex h-full items-stretch gap-x-5"
query={ChecksPageQuery}
className="h-full"
currentOrganization={currentOrganization ?? null}
currentProject={currentProject ?? null}
me={me ?? null}
organizations={organizationConnection ?? null}
isCDNEnabled={isCDNEnabled ?? null}
>
{() => {
return (
<>
<div className="flex flex-col gap-4">
<Heading>Schema Checks</Heading>
<div className="flex h-0 grow flex-col gap-2.5 overflow-y-auto rounded-md border border-gray-800/50 p-2.5 w-[300px]">
<div
className={cn(
'flex w-full h-full',
hasSchemaChecks || hasActiveSchemaCheck ? 'flex-row gap-x-6' : '',
)}
>
<div>
<div className="py-6">
<Title>Schema Checks</Title>
<Subtitle>Recently checked schemas.</Subtitle>
</div>
{hasSchemaChecks ? (
<div className="flex flex-col gap-5">
<div className="flex w-[300px] grow flex-col gap-2.5 overflow-y-auto rounded-md border border-gray-800/50 p-2.5">
{paginationVariables.map((cursor, index) => (
<Navigation
after={cursor}
@ -593,15 +664,45 @@ function ChecksPage() {
))}
</div>
</div>
<ActiveSchemaCheck key={router.schemaCheckId} />
</>
);
}}
) : (
<div>
<div className="text-sm">
{hasActiveSchemaCheck ? 'List is empty' : 'Your schema check list is empty'}
</div>
<DocsLink href="/features/schema-registry#check-a-schema">
{hasActiveSchemaCheck
? 'Check you first schema'
: 'Learn how to check your first schema with Hive CLI'}
</DocsLink>
</div>
)}
</div>
{hasActiveSchemaCheck ? (
<div className="grow">
{schemaCheckId ? <ActiveSchemaCheck schemaCheckId={schemaCheckId} /> : null}
</div>
) : hasSchemaChecks ? (
<EmptyList
className="border-0 pt-6"
title="Select a schema check"
description="A list of your schema checks is available on the left."
/>
) : null}
</div>
</TargetLayout>
</>
);
}
function ChecksPage() {
return (
<>
<MetaTitle title="Schema Checks" />
<ChecksPageContent />
</>
);
}
export const getServerSideProps = withSessionProtection();
export default authenticated(ChecksPage);

View file

@ -1,126 +1,73 @@
import { ReactElement } from 'react';
import { useQuery } from 'urql';
import { authenticated } from '@/components/authenticated-container';
import { TargetLayout } from '@/components/layouts';
import { TargetLayout } from '@/components/layouts/target';
import { SchemaExplorerFilter } from '@/components/target/explorer/filter';
import { GraphQLObjectTypeComponent } from '@/components/target/explorer/object-type';
import {
SchemaExplorerProvider,
useSchemaExplorerContext,
} from '@/components/target/explorer/provider';
import { DataWrapper, Title } from '@/components/v2';
import { Subtitle, Title } from '@/components/ui/page';
import { MetaTitle } from '@/components/v2';
import { noSchemaVersion } from '@/components/v2/empty-list';
import { graphql } from '@/gql';
import { FragmentType, graphql, useFragment } from '@/gql';
import { useRouteSelector } from '@/lib/hooks';
import { useNotFoundRedirectOnError } from '@/lib/hooks/use-not-found-redirect-on-error';
import { withSessionProtection } from '@/lib/supertokens/guard';
const SchemaView_SchemaExplorer = graphql(`
query SchemaView_SchemaExplorer(
$organization: ID!
$project: ID!
$target: ID!
$period: DateRangeInput!
) {
target(selector: { organization: $organization, project: $project, target: $target }) {
__typename
id
latestSchemaVersion {
__typename
id
valid
explorer(usage: { period: $period }) {
query {
...GraphQLObjectTypeComponent_TypeFragment
}
mutation {
...GraphQLObjectTypeComponent_TypeFragment
}
subscription {
...GraphQLObjectTypeComponent_TypeFragment
}
}
}
const ExplorerPage_SchemaExplorerFragment = graphql(`
fragment ExplorerPage_SchemaExplorerFragment on SchemaExplorer {
query {
...GraphQLObjectTypeComponent_TypeFragment
}
operationsStats(
selector: { organization: $organization, project: $project, target: $target, period: $period }
) {
totalRequests
mutation {
...GraphQLObjectTypeComponent_TypeFragment
}
subscription {
...GraphQLObjectTypeComponent_TypeFragment
}
}
`);
function SchemaView({
organizationCleanId,
projectCleanId,
targetCleanId,
}: {
organizationCleanId: string;
projectCleanId: string;
targetCleanId: string;
}): ReactElement | null {
const { period } = useSchemaExplorerContext();
const [query] = useQuery({
query: SchemaView_SchemaExplorer,
variables: {
organization: organizationCleanId,
project: projectCleanId,
target: targetCleanId,
period,
},
requestPolicy: 'cache-first',
});
function SchemaView(props: {
explorer: FragmentType<typeof ExplorerPage_SchemaExplorerFragment>;
totalRequests: number;
}) {
const { query, mutation, subscription } = useFragment(
ExplorerPage_SchemaExplorerFragment,
props.explorer,
);
const { totalRequests } = props;
return (
<DataWrapper query={query}>
{({ data }) => {
if (!data.target?.latestSchemaVersion) {
return noSchemaVersion;
}
const { query, mutation, subscription } = data.target.latestSchemaVersion.explorer;
const { totalRequests } = data.operationsStats;
return (
<>
<div className="mb-5 flex flex-row items-center justify-between">
<div className="font-light text-gray-500">The latest published schema.</div>
</div>
<div className="flex flex-col gap-4">
<SchemaExplorerFilter
organization={{ cleanId: organizationCleanId }}
project={{ cleanId: projectCleanId }}
target={{ cleanId: targetCleanId }}
period={period}
/>
{query ? (
<GraphQLObjectTypeComponent type={query} totalRequests={totalRequests} collapsed />
) : null}
{mutation ? (
<GraphQLObjectTypeComponent
type={mutation}
totalRequests={totalRequests}
collapsed
/>
) : null}
{subscription ? (
<GraphQLObjectTypeComponent
type={subscription}
totalRequests={totalRequests}
collapsed
/>
) : null}
</div>
</>
);
}}
</DataWrapper>
<div className="flex flex-col gap-4">
{query ? (
<GraphQLObjectTypeComponent type={query} totalRequests={totalRequests} collapsed />
) : null}
{mutation ? (
<GraphQLObjectTypeComponent type={mutation} totalRequests={totalRequests} collapsed />
) : null}
{subscription ? (
<GraphQLObjectTypeComponent type={subscription} totalRequests={totalRequests} collapsed />
) : null}
</div>
);
}
const TargetExplorerPageQuery = graphql(`
query TargetExplorerPageQuery($organizationId: ID!, $projectId: ID!, $targetId: ID!) {
query TargetExplorerPageQuery(
$organizationId: ID!
$projectId: ID!
$targetId: ID!
$period: DateRangeInput!
) {
organizations {
...TargetLayout_OrganizationConnectionFragment
}
organization(selector: { organization: $organizationId }) {
organization {
...TargetLayout_OrganizationFragment
...TargetLayout_CurrentOrganizationFragment
rateLimit {
retentionInDays
}
@ -128,38 +75,111 @@ const TargetExplorerPageQuery = graphql(`
}
}
project(selector: { organization: $organizationId, project: $projectId }) {
...TargetLayout_ProjectFragment
...TargetLayout_CurrentProjectFragment
cleanId
}
targets(selector: { organization: $organizationId, project: $projectId }) {
...TargetLayout_TargetConnectionFragment
}
target(selector: { organization: $organizationId, project: $projectId, target: $targetId }) {
cleanId
latestSchemaVersion {
__typename
id
valid
explorer(usage: { period: $period }) {
...ExplorerPage_SchemaExplorerFragment
}
}
}
operationsStats(
selector: {
organization: $organizationId
project: $projectId
target: $targetId
period: $period
}
) {
totalRequests
}
me {
...TargetLayout_MeFragment
}
...TargetLayout_IsCDNEnabledFragment
}
`);
function ExplorerPageContent() {
const router = useRouteSelector();
const { period, dataRetentionInDays, setDataRetentionInDays } = useSchemaExplorerContext();
const [query] = useQuery({
query: TargetExplorerPageQuery,
variables: {
organizationId: router.organizationId,
projectId: router.projectId,
targetId: router.targetId,
period,
},
});
useNotFoundRedirectOnError(!!query.error);
if (query.error) {
return null;
}
const me = query.data?.me;
const currentOrganization = query.data?.organization?.organization;
const currentProject = query.data?.project;
const currentTarget = query.data?.target;
const organizationConnection = query.data?.organizations;
const isCDNEnabled = query.data;
const explorer = currentTarget?.latestSchemaVersion?.explorer;
const latestSchemaVersion = currentTarget?.latestSchemaVersion;
const retentionInDays = currentOrganization?.rateLimit.retentionInDays;
if (typeof retentionInDays === 'number' && dataRetentionInDays !== retentionInDays) {
setDataRetentionInDays(retentionInDays);
}
return (
<TargetLayout
value="explorer"
currentOrganization={currentOrganization ?? null}
currentProject={currentProject ?? null}
me={me ?? null}
organizations={organizationConnection ?? null}
isCDNEnabled={isCDNEnabled ?? null}
>
<div className="py-6 flex flex-row items-center justify-between">
<div>
<Title>Explore</Title>
<Subtitle>Insights from the latest version.</Subtitle>
</div>
{latestSchemaVersion ? (
<SchemaExplorerFilter
organization={{ cleanId: router.organizationId }}
project={{ cleanId: router.projectId }}
target={{ cleanId: router.targetId }}
period={period}
/>
) : null}
</div>
{latestSchemaVersion && explorer ? (
<SchemaView
totalRequests={query.data?.operationsStats.totalRequests ?? 0}
explorer={explorer}
/>
) : (
noSchemaVersion
)}
</TargetLayout>
);
}
function ExplorerPage(): ReactElement {
return (
<>
<Title title="Schema Explorer" />
<TargetLayout value="explorer" query={TargetExplorerPageQuery}>
{props =>
props.organization && props.project && props.target ? (
<SchemaExplorerProvider
dataRetentionInDays={props.organization.organization.rateLimit.retentionInDays}
>
<SchemaView
organizationCleanId={props.organization.organization.cleanId}
projectCleanId={props.project.cleanId}
targetCleanId={props.target.cleanId}
/>
</SchemaExplorerProvider>
) : null
}
</TargetLayout>
<MetaTitle title="Schema Explorer" />
<SchemaExplorerProvider>
<ExplorerPageContent />
</SchemaExplorerProvider>
</>
);
}

View file

@ -1,7 +1,6 @@
import { ReactElement } from 'react';
import { useQuery } from 'urql';
import { authenticated } from '@/components/authenticated-container';
import { TargetLayout } from '@/components/layouts';
import { TargetLayout } from '@/components/layouts/target';
import { GraphQLEnumTypeComponent } from '@/components/target/explorer/enum-type';
import { SchemaExplorerFilter } from '@/components/target/explorer/filter';
import { GraphQLInputObjectTypeComponent } from '@/components/target/explorer/input-object-type';
@ -13,42 +12,14 @@ import {
} from '@/components/target/explorer/provider';
import { GraphQLScalarTypeComponent } from '@/components/target/explorer/scalar-type';
import { GraphQLUnionTypeComponent } from '@/components/target/explorer/union-type';
import { DataWrapper, Title } from '@/components/v2';
import { Subtitle, Title } from '@/components/ui/page';
import { MetaTitle } from '@/components/v2';
import { noSchemaVersion } from '@/components/v2/empty-list';
import { FragmentType, graphql, useFragment } from '@/gql';
import { useNotFoundRedirectOnError } from '@/lib/hooks/use-not-found-redirect-on-error';
import { useRouteSelector } from '@/lib/hooks/use-route-selector';
import { withSessionProtection } from '@/lib/supertokens/guard';
const SchemaTypeExplorer_Type = graphql(`
query SchemaTypeExplorer_Type(
$organization: ID!
$project: ID!
$target: ID!
$period: DateRangeInput!
$typename: String!
) {
target(selector: { organization: $organization, project: $project, target: $target }) {
__typename
id
latestSchemaVersion {
__typename
id
valid
explorer(usage: { period: $period }) {
type(name: $typename) {
...TypeRenderFragment
}
}
}
}
operationsStats(
selector: { organization: $organization, project: $project, target: $target, period: $period }
) {
totalRequests
}
}
`);
const TypeRenderFragment = graphql(`
fragment TypeRenderFragment on GraphQLNamedType {
__typename
@ -84,66 +55,20 @@ function TypeRenderer(props: {
}
}
function SchemaTypeExplorer({
organizationCleanId,
projectCleanId,
targetCleanId,
typename,
}: {
organizationCleanId: string;
projectCleanId: string;
targetCleanId: string;
typename: string;
}): ReactElement | null {
const { period } = useSchemaExplorerContext();
const [query] = useQuery({
query: SchemaTypeExplorer_Type,
variables: {
organization: organizationCleanId,
project: projectCleanId,
target: targetCleanId,
period,
typename,
},
requestPolicy: 'cache-first',
});
return (
<DataWrapper query={query}>
{({ data }) => {
if (!data.target?.latestSchemaVersion) {
return noSchemaVersion;
}
const { type } = data.target.latestSchemaVersion.explorer;
const { totalRequests } = data.operationsStats;
if (!type) {
return <div>No type found</div>;
}
return (
<div className="space-y-4">
<SchemaExplorerFilter
organization={{ cleanId: organizationCleanId }}
project={{ cleanId: projectCleanId }}
target={{ cleanId: targetCleanId }}
period={period}
typename={typename}
/>
<TypeRenderer totalRequests={totalRequests} type={type} />
</div>
);
}}
</DataWrapper>
);
}
const TargetExplorerTypenamePageQuery = graphql(`
query TargetExplorerTypenamePageQuery($organizationId: ID!, $projectId: ID!, $targetId: ID!) {
query TargetExplorerTypenamePageQuery(
$organizationId: ID!
$projectId: ID!
$targetId: ID!
$period: DateRangeInput!
$typename: String!
) {
organizations {
...TargetLayout_OrganizationConnectionFragment
}
organization(selector: { organization: $organizationId }) {
organization {
...TargetLayout_OrganizationFragment
...TargetLayout_CurrentOrganizationFragment
cleanId
rateLimit {
retentionInDays
@ -151,19 +76,107 @@ const TargetExplorerTypenamePageQuery = graphql(`
}
}
project(selector: { organization: $organizationId, project: $projectId }) {
...TargetLayout_ProjectFragment
...TargetLayout_CurrentProjectFragment
cleanId
}
targets(selector: { organization: $organizationId, project: $projectId }) {
...TargetLayout_TargetConnectionFragment
}
target(selector: { organization: $organizationId, project: $projectId, target: $targetId }) {
cleanId
latestSchemaVersion {
__typename
id
valid
explorer(usage: { period: $period }) {
type(name: $typename) {
...TypeRenderFragment
}
}
}
}
operationsStats(
selector: {
organization: $organizationId
project: $projectId
target: $targetId
period: $period
}
) {
totalRequests
}
me {
...TargetLayout_MeFragment
}
...TargetLayout_IsCDNEnabledFragment
}
`);
function ExplorerPage(): ReactElement | null {
function TypeExplorerPageContent({ typename }: { typename: string }) {
const router = useRouteSelector();
const { period, dataRetentionInDays, setDataRetentionInDays } = useSchemaExplorerContext();
const [query] = useQuery({
query: TargetExplorerTypenamePageQuery,
variables: {
organizationId: router.organizationId,
projectId: router.projectId,
targetId: router.targetId,
period,
typename,
},
});
useNotFoundRedirectOnError(!!query.error);
if (query.error) {
return null;
}
const me = query.data?.me;
const currentOrganization = query.data?.organization?.organization;
const currentProject = query.data?.project;
const currentTarget = query.data?.target;
const organizationConnection = query.data?.organizations;
const isCDNEnabled = query.data;
const type = currentTarget?.latestSchemaVersion?.explorer.type;
const latestSchemaVersion = currentTarget?.latestSchemaVersion;
const retentionInDays = currentOrganization?.rateLimit.retentionInDays;
if (typeof retentionInDays === 'number' && dataRetentionInDays !== retentionInDays) {
setDataRetentionInDays(retentionInDays);
}
return (
<TargetLayout
value="explorer"
currentOrganization={currentOrganization ?? null}
currentProject={currentProject ?? null}
me={me ?? null}
organizations={organizationConnection ?? null}
isCDNEnabled={isCDNEnabled ?? null}
>
<div className="py-6 flex flex-row items-center justify-between">
<div>
<Title>Explore</Title>
<Subtitle>Insights from the latest version.</Subtitle>
</div>
{latestSchemaVersion && type ? (
<SchemaExplorerFilter
organization={{ cleanId: router.organizationId }}
project={{ cleanId: router.projectId }}
target={{ cleanId: router.targetId }}
period={period}
/>
) : null}
</div>
{latestSchemaVersion && type ? (
<TypeRenderer totalRequests={query.data?.operationsStats.totalRequests ?? 0} type={type} />
) : type ? (
noSchemaVersion
) : (
<div>Not found</div>
)}
</TargetLayout>
);
}
function TypeExplorerPage() {
const router = useRouteSelector();
const { typename } = router.query;
@ -173,25 +186,14 @@ function ExplorerPage(): ReactElement | null {
return (
<>
<Title title={`Type ${typename}`} />
<TargetLayout value="explorer" query={TargetExplorerTypenamePageQuery}>
{props => (
<SchemaExplorerProvider
dataRetentionInDays={props.organization?.organization.rateLimit.retentionInDays ?? 0}
>
<SchemaTypeExplorer
organizationCleanId={props.organization?.organization.cleanId ?? ''}
projectCleanId={props.project?.cleanId ?? ''}
targetCleanId={props.target?.cleanId ?? ''}
typename={typename}
/>
</SchemaExplorerProvider>
)}
</TargetLayout>
<MetaTitle title={`Type ${typename}`} />
<SchemaExplorerProvider>
<TypeExplorerPageContent typename={typename} />
</SchemaExplorerProvider>
</>
);
}
export const getServerSideProps = withSessionProtection();
export default authenticated(ExplorerPage);
export default authenticated(TypeExplorerPage);

View file

@ -1,17 +1,19 @@
import { ReactElement, useCallback, useState } from 'react';
import NextLink from 'next/link';
import { clsx } from 'clsx';
import { useQuery } from 'urql';
import { authenticated } from '@/components/authenticated-container';
import { TargetLayout } from '@/components/layouts';
import { TargetLayout } from '@/components/layouts/target';
import { VersionErrorsAndChanges } from '@/components/target/history/errors-and-changes';
import { Badge, Button, DiffEditor, Heading, Spinner, TimeAgo, Title } from '@/components/v2';
import { Subtitle, Title } from '@/components/ui/page';
import { Badge, Button, DiffEditor, MetaTitle, Spinner, TimeAgo } from '@/components/v2';
import { noSchemaVersion } from '@/components/v2/empty-list';
import { DiffIcon } from '@/components/v2/icon';
import { graphql } from '@/gql';
import { CompareDocument, VersionsDocument } from '@/graphql';
import { CompareDocument } from '@/graphql';
import { useNotFoundRedirectOnError } from '@/lib/hooks/use-not-found-redirect-on-error';
import { useRouteSelector } from '@/lib/hooks/use-route-selector';
import { withSessionProtection } from '@/lib/supertokens/guard';
import { cn } from '@/lib/utils';
import {
CheckCircledIcon,
CrossCircledIcon,
@ -21,6 +23,34 @@ import {
} from '@radix-ui/react-icons';
import * as ToggleGroup from '@radix-ui/react-toggle-group';
const HistoryPage_VersionsPageQuery = graphql(`
query HistoryPage_VersionsPageQuery($selector: SchemaVersionsInput!, $limit: Int!, $after: ID) {
schemaVersions(selector: $selector, after: $after, limit: $limit) {
nodes {
id
date
valid
log {
... on PushedSchemaLog {
id
author
service
commit
}
... on DeletedSchemaLog {
id
deletedService
}
}
baseSchema
}
pageInfo {
hasNextPage
}
}
}
`);
// URQL's Infinite scrolling pattern
// https://formidable.com/open-source/urql/docs/basics/ui-patterns/#infinite-scrolling
function ListPage({
@ -39,7 +69,7 @@ function ListPage({
const router = useRouteSelector();
const [versionsQuery] = useQuery({
query: VersionsDocument,
query: HistoryPage_VersionsPageQuery,
variables: {
selector: {
organization: router.organizationId,
@ -58,7 +88,7 @@ function ListPage({
<>
{versions?.nodes.map(version => (
<div
className={clsx(
className={cn(
'flex flex-col rounded-md p-2.5 hover:bg-gray-800/40',
versionId === version.id && 'bg-gray-800/40',
)}
@ -79,7 +109,7 @@ function ListPage({
</div>
) : null}
<div className="mt-2.5 mb-1.5 flex align-middle text-xs font-medium text-[#c4c4c4]">
<div className={clsx('w-1/2 ', !version.valid && 'text-red-500')}>
<div className={cn('w-1/2 ', !version.valid && 'text-red-500')}>
<Badge color={version.valid ? 'green' : 'red'} /> Published{' '}
<TimeAgo date={version.date} />
</div>
@ -191,177 +221,224 @@ function ComparisonView({ versionId }: { versionId: string }) {
} | null>
).filter(isDefined);
return (
<div className="flex grow flex-col gap-4">
<div className="flex items-center justify-between">
<Heading>Schema</Heading>
<ToggleGroup.Root
className="flex space-x-1 rounded-md bg-gray-900/50 text-gray-500 p-0.5"
type="single"
defaultValue={availableViews[0]?.value}
onValueChange={onViewChange}
orientation="vertical"
>
{availableViews.map(({ value, icon, label, tooltip }) => (
<ToggleGroup.Item
key={value}
value={value}
className={clsx(
'flex items-center rounded-md py-[0.4375rem] px-2 text-xs font-semibold hover:text-white',
view === value && 'bg-gray-800 text-white',
)}
title={tooltip}
>
{icon}
<span className="ml-2">{label}</span>
</ToggleGroup.Item>
))}
</ToggleGroup.Root>
</div>
<div className="grow rounded-md border border-gray-800/50 overflow-y-auto">
{isLoading ? (
<div className="flex w-full h-full flex justify-center items-center">
<Spinner />
</div>
) : error ? (
<div className="m-3 rounded-lg bg-red-500/20 p-8">
<div className="mb-3 flex items-center gap-3">
<CrossCircledIcon className="h-6 w-auto text-red-500" />
<h2 className="text-lg font-medium text-white">Failed to compare schemas</h2>
</div>
<p className="text-base text-gray-500">
Previous or current schema is most likely incomplete and was force published
</p>
<pre className="mt-5 whitespace-pre-wrap rounded-lg bg-red-900 p-3 text-xs text-white">
{error.graphQLErrors?.[0]?.message ?? error.networkError?.message}
</pre>
</div>
) : showFullSchemaDiff ? (
<DiffEditor
title="Full schema"
before={comparison.diff.before ?? ''}
after={comparison.diff.after}
/>
) : showServiceSchemaDiff ? (
<DiffEditor
title={comparison.service?.name ?? ''}
before={comparison.service?.before ?? ''}
after={comparison.service?.after ?? ''}
/>
) : showListView ? (
<VersionErrorsAndChanges
changes={
hasSchemaChanges
? comparison.changes
: {
nodes: [],
total: 0,
}
}
errors={
hasCompositionErrors
? compositionErrors
: {
nodes: [],
total: 0,
}
}
/>
) : isServiceSchemaAvailable ? (
<DiffEditor
title={comparison.service?.name ?? ''}
before={comparison.service?.before ?? ''}
after={comparison.service?.after ?? ''}
/>
) : (
<div>
<div className="m-3 rounded-lg bg-emerald-500/20 p-8">
<div className="mb-3 flex items-center gap-3">
<CheckCircledIcon className="h-6 w-auto text-emerald-500" />
<h2 className="text-lg font-medium text-white">First composable version</h2>
</div>
<p className="text-base text-white">
Congratulations! This is the first version of the schema that is composable.
</p>
</div>
</div>
)}
</div>
</div>
);
}
function Page({ versionId, gitRepository }: { versionId: string; gitRepository?: string }) {
const [pageVariables, setPageVariables] = useState([{ limit: 10, after: '' }]);
return (
<>
<div className="flex flex-col gap-5">
<Heading>Versions</Heading>
<div className="flex h-0 min-w-[420px] grow flex-col gap-2.5 overflow-y-auto rounded-md border border-gray-800/50 p-2.5">
{pageVariables.map((variables, i) => (
<ListPage
gitRepository={gitRepository}
key={variables.after || 'initial'}
variables={variables}
isLastPage={i === pageVariables.length - 1}
onLoadMore={after => {
setPageVariables([...pageVariables, { after, limit: 10 }]);
}}
versionId={versionId}
<div className="flex flex-row justify-between items-center">
<div className="py-6">
<Title>Details</Title>
<Subtitle>Explore details of the selected version</Subtitle>
</div>
{availableViews.length ? (
<div className="flex items-center justify-between">
<ToggleGroup.Root
className="flex space-x-1 rounded-md bg-gray-900/50 text-gray-500 p-0.5"
type="single"
defaultValue={availableViews[0]?.value}
onValueChange={onViewChange}
orientation="vertical"
>
{availableViews.map(({ value, icon, label, tooltip }) => (
<ToggleGroup.Item
key={value}
value={value}
className={cn(
'flex items-center rounded-md py-[0.4375rem] px-2 text-xs font-semibold hover:text-white',
view === value && 'bg-gray-800 text-white',
)}
title={tooltip}
>
{icon}
<span className="ml-2">{label}</span>
</ToggleGroup.Item>
))}
</ToggleGroup.Root>
</div>
) : null}
</div>
<div className="flex h-full">
<div className="grow rounded-md overflow-y-auto">
{isLoading ? (
<div className="flex w-full h-full justify-center items-center">
<Spinner />
</div>
) : error ? (
<div className="m-3 rounded-lg bg-red-500/20 p-8">
<div className="mb-3 flex items-center gap-3">
<CrossCircledIcon className="h-6 w-auto text-red-500" />
<h2 className="text-lg font-medium text-white">Failed to compare schemas</h2>
</div>
<p className="text-base text-gray-500">
Previous or current schema is most likely incomplete and was force published
</p>
<pre className="mt-5 whitespace-pre-wrap rounded-lg bg-red-900 p-3 text-xs text-white">
{error.graphQLErrors?.[0]?.message ?? error.networkError?.message}
</pre>
</div>
) : showFullSchemaDiff ? (
<DiffEditor
title="Full schema"
before={comparison.diff.before ?? ''}
after={comparison.diff.after}
/>
))}
) : showServiceSchemaDiff ? (
<DiffEditor
title={comparison.service?.name ?? ''}
before={comparison.service?.before ?? ''}
after={comparison.service?.after ?? ''}
/>
) : showListView ? (
<VersionErrorsAndChanges
changes={
hasSchemaChanges
? comparison.changes
: {
nodes: [],
total: 0,
}
}
errors={
hasCompositionErrors
? compositionErrors
: {
nodes: [],
total: 0,
}
}
/>
) : isServiceSchemaAvailable ? (
<DiffEditor
title={comparison.service?.name ?? ''}
before={comparison.service?.before ?? ''}
after={comparison.service?.after ?? ''}
/>
) : (
<div>
<div className="m-3 p-4">
<div className="mb-3 flex items-center gap-3">
<CheckCircledIcon className="h-4 w-auto text-emerald-500" />
<h2 className="text-base font-medium text-white">First composable version</h2>
</div>
<p className="text-xs text-muted-foreground">
Congratulations! This is the first version of the schema that is composable.
</p>
</div>
</div>
)}
</div>
</div>
<ComparisonView versionId={versionId} />
</>
);
}
const TargetHistoryPageQuery = graphql(`
query TargetHistoryPageQuery($organizationId: ID!, $projectId: ID!, $targetId: ID!) {
organizations {
...TargetLayout_OrganizationConnectionFragment
}
organization(selector: { organization: $organizationId }) {
organization {
...TargetLayout_OrganizationFragment
...TargetLayout_CurrentOrganizationFragment
}
}
project(selector: { organization: $organizationId, project: $projectId }) {
...TargetLayout_ProjectFragment
...TargetLayout_CurrentProjectFragment
gitRepository
}
targets(selector: { organization: $organizationId, project: $projectId }) {
...TargetLayout_TargetConnectionFragment
}
target(selector: { organization: $organizationId, project: $projectId, target: $targetId }) {
id
latestSchemaVersion {
id
}
}
me {
...TargetLayout_MeFragment
}
...TargetLayout_IsCDNEnabledFragment
}
`);
function HistoryPage(): ReactElement {
function HistoryPageContent() {
const router = useRouteSelector();
const [query] = useQuery({
query: TargetHistoryPageQuery,
variables: {
organizationId: router.organizationId,
projectId: router.projectId,
targetId: router.targetId,
},
});
useNotFoundRedirectOnError(!!query.error);
const [pageVariables, setPageVariables] = useState([{ limit: 10, after: '' }]);
if (query.error) {
return null;
}
const me = query.data?.me;
const currentOrganization = query.data?.organization?.organization;
const currentProject = query.data?.project;
const currentTarget = query.data?.target;
const organizationConnection = query.data?.organizations;
const isCDNEnabled = query.data;
const versionId = router.versionId ?? currentTarget?.latestSchemaVersion?.id;
return (
<TargetLayout
value="history"
className="h-full"
currentOrganization={currentOrganization ?? null}
currentProject={currentProject ?? null}
me={me ?? null}
organizations={organizationConnection ?? null}
isCDNEnabled={isCDNEnabled ?? null}
>
{versionId ? (
<div className="flex w-full h-full flex-row gap-x-6">
<div>
<div className="py-6">
<Title>Versions</Title>
<Subtitle>Recently published schemas.</Subtitle>
</div>
<div className="flex flex-col gap-5">
<div className="flex min-w-[420px] grow flex-col gap-2.5 overflow-y-auto rounded-md border border-gray-800/50 p-2.5">
{pageVariables.map((variables, i) => (
<ListPage
gitRepository={currentProject?.gitRepository ?? undefined}
key={variables.after || 'initial'}
variables={variables}
isLastPage={i === pageVariables.length - 1}
onLoadMore={after => {
setPageVariables([...pageVariables, { after, limit: 10 }]);
}}
versionId={versionId}
/>
))}
</div>
</div>
</div>
<div className="grow">
<ComparisonView versionId={versionId} />
</div>
</div>
) : (
<>
<div className="py-6">
<Title>Versions</Title>
<Subtitle>Recently published schemas.</Subtitle>
</div>
{noSchemaVersion}
</>
)}
</TargetLayout>
);
}
function HistoryPage(): ReactElement {
return (
<>
<Title title="History" />
<TargetLayout
value="history"
className="flex h-full items-stretch gap-x-5"
query={TargetHistoryPageQuery}
>
{({ target, project }) => {
const versionId = router.versionId ?? target?.latestSchemaVersion?.id;
return versionId ? (
<Page gitRepository={project?.gitRepository ?? undefined} versionId={versionId} />
) : (
noSchemaVersion
);
}}
</TargetLayout>
<MetaTitle title="History" />
<HistoryPageContent />
</>
);
}

View file

@ -2,24 +2,33 @@ import { ChangeEventHandler, ReactElement, useCallback, useState } from 'react';
import { useQuery } from 'urql';
import { useDebouncedCallback } from 'use-debounce';
import { authenticated } from '@/components/authenticated-container';
import { TargetLayout } from '@/components/layouts';
import { TargetLayout } from '@/components/layouts/target';
import { MarkAsValid } from '@/components/target/history/MarkAsValid';
import { Accordion, DataWrapper, GraphQLBlock, Input, noSchema, Title } from '@/components/v2';
import { Subtitle, Title } from '@/components/ui/page';
import { Accordion, GraphQLBlock, Input, MetaTitle, noSchema } from '@/components/v2';
import { noSchemaVersion } from '@/components/v2/empty-list';
import { GraphQLHighlight } from '@/components/v2/graphql-block';
import { FragmentType, graphql, useFragment } from '@/gql';
import { CompositeSchemaFieldsFragment, SingleSchemaFieldsFragment } from '@/gql/graphql';
import { LatestSchemaDocument, ProjectType, RegistryModel } from '@/graphql';
import { DocumentType, FragmentType, graphql, useFragment } from '@/gql';
import { ProjectType, RegistryModel } from '@/graphql';
import { TargetAccessScope, useTargetAccess } from '@/lib/access/target';
import { useRouteSelector } from '@/lib/hooks';
import { useNotFoundRedirectOnError } from '@/lib/hooks/use-not-found-redirect-on-error';
import { withSessionProtection } from '@/lib/supertokens/guard';
type CompositeSchema = Extract<
DocumentType<typeof SchemaView_SchemaFragment>,
{
__typename?: 'CompositeSchema';
}
>;
function isCompositeSchema(
schema: SingleSchemaFieldsFragment | CompositeSchemaFieldsFragment,
): schema is CompositeSchemaFieldsFragment {
schema: DocumentType<typeof SchemaView_SchemaFragment>,
): schema is CompositeSchema {
return schema.__typename === 'CompositeSchema';
}
function SchemaBlock({ schema }: { schema: CompositeSchemaFieldsFragment }) {
function SchemaBlock({ schema }: { schema: CompositeSchema }) {
return (
<Accordion.Item value={schema.id} key={schema.id} className="border-2 border-gray-900/50">
<Accordion.Header>
@ -47,14 +56,14 @@ const Schemas_ProjectFragment = graphql(`
function Schemas({
filterService,
schemas = [],
...props
}: {
project: FragmentType<typeof Schemas_ProjectFragment>;
schemas: Array<SingleSchemaFieldsFragment | CompositeSchemaFieldsFragment>;
schemas: FragmentType<typeof SchemaView_SchemaFragment>[];
filterService?: string;
}): ReactElement {
const project = useFragment(Schemas_ProjectFragment, props.project);
const schemas = useFragment(SchemaView_SchemaFragment, props.schemas);
if (project.type === ProjectType.Single) {
const [schema] = schemas;
@ -116,13 +125,35 @@ const SchemaView_ProjectFragment = graphql(`
}
`);
const SchemaView_SchemaFragment = graphql(`
fragment SchemaView_SchemaFragment on Schema {
... on SingleSchema {
id
author
source
commit
}
... on CompositeSchema {
id
author
source
service
url
commit
}
}
`);
const SchemaView_TargetFragment = graphql(`
fragment SchemaView_TargetFragment on Target {
cleanId
latestSchemaVersion {
id
valid
schemas {
nodes {
__typename
...SchemaView_SchemaFragment
}
}
}
@ -157,104 +188,130 @@ function SchemaView(props: {
const isDistributed =
project.type === ProjectType.Federation || project.type === ProjectType.Stitching;
const [query] = useQuery({
query: LatestSchemaDocument,
variables: {
selector: {
organization: organization.cleanId,
project: project.cleanId,
target: target.cleanId,
},
},
requestPolicy: 'cache-first',
});
const canManage = useTargetAccess({
scope: TargetAccessScope.RegistryWrite,
member: organization.me,
redirect: false,
});
const { latestSchemaVersion } = target;
if (!latestSchemaVersion) {
return noSchemaVersion;
}
if (!latestSchemaVersion.schemas.nodes.length) {
return noSchema;
}
const canMarkAsValid = project.registryModel === RegistryModel.Legacy;
const showExtra = canManage && project.registryModel === RegistryModel.Legacy;
return (
<DataWrapper query={query}>
{query => {
const latestSchemaVersion = query.data?.target?.latestSchemaVersion;
if (!latestSchemaVersion) {
return noSchemaVersion;
}
if (!latestSchemaVersion.schemas.nodes.length) {
return noSchema;
}
return (
<>
<div className="mb-5 flex flex-row items-center justify-between">
<div className="font-light text-gray-500">The latest published schema.</div>
<div className="flex flex-row items-center gap-4">
{isDistributed && (
<Input
placeholder="Find service"
value={term}
onChange={handleChange}
onClear={reset}
size="small"
/>
)}
{canManage && project.registryModel === RegistryModel.Legacy ? (
<>
<MarkAsValid version={latestSchemaVersion} />{' '}
</>
) : null}
</div>
</div>
<Schemas
project={project}
filterService={filterService}
schemas={latestSchemaVersion.schemas.nodes ?? []}
/>
</>
);
}}
</DataWrapper>
<>
{showExtra ? (
<div className="mb-5 flex flex-row items-center justify-between">
<div className="flex flex-row items-center gap-4">
{isDistributed && (
<Input
placeholder="Find service"
value={term}
onChange={handleChange}
onClear={reset}
size="small"
/>
)}
{canMarkAsValid ? (
<>
<MarkAsValid version={latestSchemaVersion} />{' '}
</>
) : null}
</div>
</div>
) : null}
<Schemas
project={project}
filterService={filterService}
schemas={target.latestSchemaVersion?.schemas.nodes ?? []}
/>
</>
);
}
const TargetSchemaPageQuery = graphql(`
query TargetSchemaPageQuery($organizationId: ID!, $projectId: ID!, $targetId: ID!) {
organizations {
...TargetLayout_OrganizationConnectionFragment
}
organization(selector: { organization: $organizationId }) {
organization {
...TargetLayout_OrganizationFragment
...TargetLayout_CurrentOrganizationFragment
...SchemaView_OrganizationFragment
}
}
project(selector: { organization: $organizationId, project: $projectId }) {
...TargetLayout_ProjectFragment
...TargetLayout_CurrentProjectFragment
...SchemaView_ProjectFragment
}
targets(selector: { organization: $organizationId, project: $projectId }) {
...TargetLayout_TargetConnectionFragment
}
target(selector: { organization: $organizationId, project: $projectId, target: $targetId }) {
...SchemaView_TargetFragment
}
me {
...TargetLayout_MeFragment
}
...TargetLayout_IsCDNEnabledFragment
}
`);
function Page() {
const router = useRouteSelector();
const [query] = useQuery({
query: TargetSchemaPageQuery,
variables: {
organizationId: router.organizationId,
projectId: router.projectId,
targetId: router.targetId,
},
});
useNotFoundRedirectOnError(!!query.error);
if (query.error) {
return null;
}
const me = query.data?.me;
const currentOrganization = query.data?.organization?.organization;
const currentProject = query.data?.project;
const organizationConnection = query.data?.organizations;
const target = query.data?.target;
const isCDNEnabled = query.data;
return (
<TargetLayout
value="schema"
currentOrganization={currentOrganization ?? null}
currentProject={currentProject ?? null}
me={me ?? null}
organizations={organizationConnection ?? null}
isCDNEnabled={isCDNEnabled ?? null}
>
<div className="py-6">
<Title>Schema</Title>
<Subtitle>The latest published schema.</Subtitle>
</div>
<div>
{currentOrganization && currentProject && target ? (
<SchemaView organization={currentOrganization} project={currentProject} target={target} />
) : null}
</div>
</TargetLayout>
);
}
function SchemaPage(): ReactElement {
return (
<>
<Title title="Schema" />
<TargetLayout value="schema" query={TargetSchemaPageQuery}>
{props => (
<SchemaView
target={props.target!}
organization={props.organization!.organization}
project={props.project!}
/>
)}
</TargetLayout>
<MetaTitle title="Schema" />
<Page />
</>
);
}

View file

@ -1,39 +1,43 @@
import { ReactElement, useEffect, useRef, useState } from 'react';
import { ReactElement, useEffect, useState } from 'react';
import { useRouter } from 'next/router';
import clsx from 'clsx';
import { GraphiQL } from 'graphiql';
import { LinkIcon } from 'lucide-react';
import { useMutation, useQuery } from 'urql';
import { authenticated } from '@/components/authenticated-container';
import { TargetLayout } from '@/components/layouts';
import { TargetLayout_OrganizationFragment } from '@/components/layouts/target';
import {
Accordion,
Button,
DocsLink,
DocsNote,
EmptyList,
Heading,
Link,
Spinner,
Title,
} from '@/components/v2';
import { TargetLayout } from '@/components/layouts/target';
import { Button } from '@/components/ui/button';
import { Subtitle, Title } from '@/components/ui/page';
import { Accordion, DocsLink, EmptyList, Link, MetaTitle, Spinner } from '@/components/v2';
import { HiveLogo, SaveIcon } from '@/components/v2/icon';
import {
ConnectLabModal,
CreateCollectionModal,
CreateOperationModal,
DeleteCollectionModal,
DeleteOperationModal,
} from '@/components/v2/modals';
import { FragmentType, graphql, useFragment } from '@/gql';
import { ConnectLabModal } from '@/components/v2/modals/connect-lab';
import { graphql } from '@/gql';
import { TargetAccessScope } from '@/gql/graphql';
import { canAccessTarget, CanAccessTarget_MemberFragment } from '@/lib/access/target';
import { useClipboard, useNotifications, useRouteSelector, useToggle } from '@/lib/hooks';
import { useCollections } from '@/lib/hooks/use-collections';
import { canAccessTarget } from '@/lib/access/target';
import {
useClipboard,
useCollections,
useNotifications,
useRouteSelector,
useToggle,
} from '@/lib/hooks';
import { useNotFoundRedirectOnError } from '@/lib/hooks/use-not-found-redirect-on-error';
import { withSessionProtection } from '@/lib/supertokens/guard';
import { DropdownMenu, GraphiQLPlugin, Tooltip, useEditorContext } from '@graphiql/react';
import { cn } from '@/lib/utils';
import {
Button as GraphiQLButton,
DropdownMenu as GraphiQLDropdownMenu,
GraphiQLPlugin,
Tooltip as GraphiQLTooltip,
useEditorContext,
} from '@graphiql/react';
import { createGraphiQLFetcher } from '@graphiql/toolkit';
import { BookmarkIcon, DotsVerticalIcon, Link1Icon, Share2Icon } from '@radix-ui/react-icons';
import { BookmarkIcon, DotsVerticalIcon, Share2Icon } from '@radix-ui/react-icons';
import 'graphiql/graphiql.css';
function Share(): ReactElement {
@ -42,8 +46,8 @@ function Share(): ReactElement {
const router = useRouter();
return (
<Tooltip label={label}>
<Button
<GraphiQLTooltip label={label}>
<GraphiQLButton
className="graphiql-toolbar-button"
aria-label={label}
disabled={!router.query.operation}
@ -52,8 +56,8 @@ function Share(): ReactElement {
}}
>
<Share2Icon className="graphiql-toolbar-icon" />
</Button>
</Tooltip>
</GraphiQLButton>
</GraphiQLTooltip>
);
}
@ -94,13 +98,14 @@ function useCurrentOperation() {
return operationId ? data?.target?.documentCollectionOperation : null;
}
function useOperationCollectionsPlugin(props: {
meRef: FragmentType<typeof CanAccessTarget_MemberFragment>;
}) {
const propsRef = useRef(props);
propsRef.current = props;
const pluginRef = useRef<GraphiQLPlugin>();
pluginRef.current ||= {
function useOperationCollectionsPlugin({
canDelete,
canEdit,
}: {
canEdit: boolean;
canDelete: boolean;
}): GraphiQLPlugin {
return {
title: 'Operation Collections',
icon: BookmarkIcon,
content: function Content() {
@ -153,8 +158,6 @@ function useOperationCollectionsPlugin(props: {
}
}, [hasAllEditors, queryParamsOperationId, currentOperation]);
const canEdit = canAccessTarget(TargetAccessScope.Settings, props.meRef);
const canDelete = canAccessTarget(TargetAccessScope.Delete, props.meRef);
const shouldShowMenu = canEdit || canDelete;
const initialSelectedCollection =
@ -166,7 +169,7 @@ function useOperationCollectionsPlugin(props: {
return (
<>
<div className="flex justify-between">
<Heading>Collections</Heading>
<Title>Collections</Title>
{canEdit ? (
<Button
variant="link"
@ -180,7 +183,7 @@ function useOperationCollectionsPlugin(props: {
</Button>
) : null}
</div>
<p className="mb-3 font-light text-gray-300 text-sm">Shared across your organization</p>
<p className="mb-3 font-light text-gray-300 text-xs">Shared across your organization</p>
{loading ? (
<Spinner />
) : (
@ -207,20 +210,20 @@ function useOperationCollectionsPlugin(props: {
<Accordion.Header>{collection.name}</Accordion.Header>
{shouldShowMenu ? (
<DropdownMenu
<GraphiQLDropdownMenu
// https://github.com/radix-ui/primitives/issues/1241#issuecomment-1580887090
modal={false}
>
<DropdownMenu.Button
<GraphiQLDropdownMenu.Button
className="graphiql-toolbar-button !shrink-0"
aria-label="More"
data-cy="collection-3-dots"
>
<DotsVerticalIcon />
</DropdownMenu.Button>
</GraphiQLDropdownMenu.Button>
<DropdownMenu.Content>
<DropdownMenu.Item
<GraphiQLDropdownMenu.Content>
<GraphiQLDropdownMenu.Item
onSelect={() => {
setCollectionId(collection.id);
toggleCollectionModal();
@ -228,8 +231,8 @@ function useOperationCollectionsPlugin(props: {
data-cy="collection-edit"
>
Edit
</DropdownMenu.Item>
<DropdownMenu.Item
</GraphiQLDropdownMenu.Item>
<GraphiQLDropdownMenu.Item
onSelect={() => {
setCollectionId(collection.id);
toggleDeleteCollectionModalOpen();
@ -238,9 +241,9 @@ function useOperationCollectionsPlugin(props: {
data-cy="collection-delete"
>
Delete
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu>
</GraphiQLDropdownMenu.Item>
</GraphiQLDropdownMenu.Content>
</GraphiQLDropdownMenu>
) : null}
</div>
<Accordion.Content className="pr-0">
@ -256,7 +259,7 @@ function useOperationCollectionsPlugin(props: {
targetId: router.targetId,
},
}}
className={clsx(
className={cn(
'hover:bg-gray-100/10 w-full rounded p-2 !text-gray-300',
router.query.operation === node.id && 'bg-gray-100/10',
)}
@ -280,20 +283,20 @@ function useOperationCollectionsPlugin(props: {
>
{node.name}
</Link>
<DropdownMenu
<GraphiQLDropdownMenu
// https://github.com/radix-ui/primitives/issues/1241#issuecomment-1580887090
modal={false}
>
<DropdownMenu.Button
<GraphiQLDropdownMenu.Button
className="graphiql-toolbar-button opacity-0 [div:hover>&]:opacity-100 transition"
aria-label="More"
data-cy="operation-3-dots"
>
<DotsVerticalIcon />
</DropdownMenu.Button>
</GraphiQLDropdownMenu.Button>
<DropdownMenu.Content>
<DropdownMenu.Item
<GraphiQLDropdownMenu.Content>
<GraphiQLDropdownMenu.Item
onSelect={async () => {
const url = new URL(window.location.href);
await copyToClipboard(
@ -302,9 +305,9 @@ function useOperationCollectionsPlugin(props: {
}}
>
Copy link to operation
</DropdownMenu.Item>
</GraphiQLDropdownMenu.Item>
{canDelete ? (
<DropdownMenu.Item
<GraphiQLDropdownMenu.Item
onSelect={() => {
setOperationId(node.id);
toggleDeleteOperationModalOpen();
@ -313,10 +316,10 @@ function useOperationCollectionsPlugin(props: {
data-cy="remove-operation"
>
Delete
</DropdownMenu.Item>
</GraphiQLDropdownMenu.Item>
) : null}
</DropdownMenu.Content>
</DropdownMenu>
</GraphiQLDropdownMenu.Content>
</GraphiQLDropdownMenu>
</div>
))
: 'No operations yet. Use the editor to create an operation, and click Save to store and share it.'}
@ -335,8 +338,6 @@ function useOperationCollectionsPlugin(props: {
);
},
};
return pluginRef.current;
}
const UpdateOperationMutation = graphql(`
@ -377,7 +378,7 @@ function Save(): ReactElement {
const operationId = currentOperation?.id;
const label = isSame ? undefined : operationId ? 'Update saved operation' : 'Save operation';
const button = (
<Button
<GraphiQLButton
className="graphiql-toolbar-button"
data-cy="save-collection"
aria-label={label}
@ -415,34 +416,106 @@ function Save(): ReactElement {
}}
>
<SaveIcon className="graphiql-toolbar-icon !h-5 w-auto" />
</Button>
</GraphiQLButton>
);
return (
<>
{label ? <Tooltip label={label}>{button}</Tooltip> : button}
{label ? <GraphiQLTooltip label={label}>{button}</GraphiQLTooltip> : button}
{isOpen ? <CreateOperationModal isOpen={isOpen} toggleModalOpen={toggle} /> : null}
</>
);
}
// Save.whyDidYouRender = true;
const TargetLaboratoryPageQuery = graphql(`
query TargetLaboratoryPageQuery($organizationId: ID!, $projectId: ID!, $targetId: ID!) {
organizations {
...TargetLayout_OrganizationConnectionFragment
}
organization(selector: { organization: $organizationId }) {
organization {
...TargetLayout_CurrentOrganizationFragment
me {
...CanAccessTarget_MemberFragment
}
}
}
project(selector: { organization: $organizationId, project: $projectId }) {
...TargetLayout_CurrentProjectFragment
}
target(selector: { organization: $organizationId, project: $projectId, target: $targetId }) {
id
}
me {
...TargetLayout_MeFragment
}
...TargetLayout_IsCDNEnabledFragment
}
`);
function LaboratoryPageContent() {
const [isModalOpen, toggleModalOpen] = useToggle();
const router = useRouteSelector();
const [query] = useQuery({
query: TargetLaboratoryPageQuery,
variables: {
organizationId: router.organizationId,
projectId: router.projectId,
targetId: router.targetId,
},
});
useNotFoundRedirectOnError(!!query.error);
const endpoint = `${location.origin}/api/lab/${router.organizationId}/${router.projectId}/${router.targetId}`;
const me = query.data?.me;
const currentOrganization = query.data?.organization?.organization;
const currentProject = query.data?.project;
const organizationConnection = query.data?.organizations;
const isCDNEnabled = query.data;
const operationCollectionsPlugin = useOperationCollectionsPlugin({
canEdit: canAccessTarget(TargetAccessScope.Settings, currentOrganization?.me ?? null),
canDelete: canAccessTarget(TargetAccessScope.Delete, currentOrganization?.me ?? null),
});
if (query.error) {
return null;
}
function Page({
endpoint,
organizationRef,
}: {
endpoint: string;
organizationRef: FragmentType<typeof TargetLayout_OrganizationFragment>;
}): ReactElement {
const { me } = useFragment(TargetLayout_OrganizationFragment, organizationRef);
const operationCollectionsPlugin = useOperationCollectionsPlugin({ meRef: me });
return (
<>
<DocsNote>
Explore your GraphQL schema and run queries against a mocked version of your GraphQL
service. <DocsLink href="/features/laboratory">Learn more about the Laboratory</DocsLink>
</DocsNote>
<TargetLayout
value="laboratory"
currentOrganization={currentOrganization ?? null}
currentProject={currentProject ?? null}
me={me ?? null}
organizations={organizationConnection ?? null}
isCDNEnabled={isCDNEnabled ?? null}
connect={
<div>
<Button onClick={toggleModalOpen} variant="link" className="text-orange-500">
<LinkIcon size={16} className="mr-2" />
Use Schema Externally
</Button>
<ConnectLabModal
isOpen={isModalOpen}
toggleModalOpen={toggleModalOpen}
endpoint={endpoint}
/>
</div>
}
>
<div className="py-6">
<Title>Laboratory</Title>
<Subtitle>
Explore your GraphQL schema and run queries against a mocked version of your GraphQL
service.
</Subtitle>
<p>
<DocsLink className="text-muted-foreground text-sm" href="/features/laboratory">
Learn more about the Laboratory
</DocsLink>
</p>
</div>
<style global jsx>{`
.graphiql-container {
--color-base: transparent !important;
@ -450,79 +523,36 @@ function Page({
min-height: 600px;
}
`}</style>
<GraphiQL
fetcher={createGraphiQLFetcher({ url: endpoint })}
toolbar={{
additionalContent: (
<>
<Save />
<Share />
</>
),
}}
showPersistHeadersSettings={false}
shouldPersistHeaders={false}
plugins={[operationCollectionsPlugin]}
visiblePlugin={operationCollectionsPlugin}
>
<GraphiQL.Logo>
<HiveLogo className="h-6 w-auto" />
</GraphiQL.Logo>
</GraphiQL>
</>
{query.fetching ? null : (
<GraphiQL
fetcher={createGraphiQLFetcher({ url: endpoint })}
toolbar={{
additionalContent: (
<>
<Save />
<Share />
</>
),
}}
showPersistHeadersSettings={false}
shouldPersistHeaders={false}
plugins={[operationCollectionsPlugin]}
visiblePlugin={operationCollectionsPlugin}
>
<GraphiQL.Logo>
<HiveLogo className="h-6 w-auto" />
</GraphiQL.Logo>
</GraphiQL>
)}
</TargetLayout>
);
}
const TargetLaboratoryPageQuery = graphql(`
query TargetLaboratoryPageQuery($organizationId: ID!, $projectId: ID!, $targetId: ID!) {
organization(selector: { organization: $organizationId }) {
organization {
...TargetLayout_OrganizationFragment
}
}
project(selector: { organization: $organizationId, project: $projectId }) {
...TargetLayout_ProjectFragment
}
targets(selector: { organization: $organizationId, project: $projectId }) {
...TargetLayout_TargetConnectionFragment
}
target(selector: { organization: $organizationId, project: $projectId, target: $targetId }) {
id
}
...TargetLayout_IsCDNEnabledFragment
}
`);
function LaboratoryPage(): ReactElement {
const [isModalOpen, toggleModalOpen] = useToggle();
const router = useRouteSelector();
const endpoint = `${window.location.origin}/api/lab/${router.organizationId}/${router.projectId}/${router.targetId}`;
return (
<>
<Title title="Schema laboratory" />
<TargetLayout
query={TargetLaboratoryPageQuery}
value="laboratory"
className="flex h-full flex-col"
connect={
<>
<Button size="large" variant="primary" onClick={toggleModalOpen} className="ml-auto">
Use Schema Externally
<Link1Icon className="ml-8 h-6 w-auto" />
</Button>
<ConnectLabModal
isOpen={isModalOpen}
toggleModalOpen={toggleModalOpen}
endpoint={endpoint}
/>
</>
}
>
{({ organization }) => (
<Page organizationRef={organization!.organization} endpoint={endpoint} />
)}
</TargetLayout>
<MetaTitle title="Schema laboratory" />
<LaboratoryPageContent />
</>
);
}

View file

@ -1,13 +1,17 @@
import { ReactElement, useCallback, useMemo, useState } from 'react';
import { useRouter } from 'next/router';
import { formatISO, subDays, subHours, subMinutes } from 'date-fns';
import { useQuery } from 'urql';
import { authenticated } from '@/components/authenticated-container';
import { TargetLayout } from '@/components/layouts';
import { TargetLayout } from '@/components/layouts/target';
import { OperationsFilterTrigger } from '@/components/target/operations/Filters';
import { OperationsList } from '@/components/target/operations/List';
import { OperationsStats } from '@/components/target/operations/Stats';
import { EmptyList, RadixSelect, Title } from '@/components/v2';
import { Subtitle, Title } from '@/components/ui/page';
import { EmptyList, MetaTitle, RadixSelect } from '@/components/v2';
import { graphql } from '@/gql';
import { useRouteSelector } from '@/lib/hooks';
import { useNotFoundRedirectOnError } from '@/lib/hooks/use-not-found-redirect-on-error';
import { withSessionProtection } from '@/lib/supertokens/guard';
function floorDate(date: Date): Date {
@ -66,17 +70,23 @@ function OperationsView({
return (
<>
<div className="flex justify-end gap-2 pb-7">
<OperationsFilterTrigger
period={period}
selected={selectedOperations}
onFilter={setSelectedOperations}
/>
<RadixSelect
onChange={updatePeriod}
defaultValue={selectedPeriod}
options={Object.entries(DateRange).map(([value, label]) => ({ value, label }))}
/>
<div className="py-6 flex flex-row items-center justify-between">
<div>
<Title>Versions</Title>
<Subtitle>Recently published schemas.</Subtitle>
</div>
<div className="flex justify-end gap-x-2">
<OperationsFilterTrigger
period={period}
selected={selectedOperations}
onFilter={setSelectedOperations}
/>
<RadixSelect
onChange={updatePeriod}
defaultValue={selectedPeriod}
options={Object.entries(DateRange).map(([value, label]) => ({ value, label }))}
/>
</div>
</div>
<OperationsStats
organization={organizationCleanId}
@ -99,54 +109,91 @@ function OperationsView({
const TargetOperationsPageQuery = graphql(`
query TargetOperationsPageQuery($organizationId: ID!, $projectId: ID!, $targetId: ID!) {
organizations {
...TargetLayout_OrganizationConnectionFragment
}
organization(selector: { organization: $organizationId }) {
organization {
...TargetLayout_OrganizationFragment
...TargetLayout_CurrentOrganizationFragment
cleanId
}
}
project(selector: { organization: $organizationId, project: $projectId }) {
...TargetLayout_ProjectFragment
...TargetLayout_CurrentProjectFragment
cleanId
}
targets(selector: { organization: $organizationId, project: $projectId }) {
...TargetLayout_TargetConnectionFragment
}
target(selector: { organization: $organizationId, project: $projectId, target: $targetId }) {
cleanId
}
hasCollectedOperations(
selector: { organization: $organizationId, project: $projectId, target: $targetId }
)
me {
...TargetLayout_MeFragment
}
...TargetLayout_IsCDNEnabledFragment
}
`);
function TargetOperationsPageContent() {
const router = useRouteSelector();
const [query] = useQuery({
query: TargetOperationsPageQuery,
variables: {
organizationId: router.organizationId,
projectId: router.projectId,
targetId: router.targetId,
},
});
useNotFoundRedirectOnError(!!query.error);
if (query.error) {
return null;
}
const me = query.data?.me;
const currentOrganization = query.data?.organization?.organization;
const currentProject = query.data?.project;
const currentTarget = query.data?.target;
const organizationConnection = query.data?.organizations;
const isCDNEnabled = query.data;
const hasCollectedOperations = query.data?.hasCollectedOperations === true;
return (
<TargetLayout
value="operations"
currentOrganization={currentOrganization ?? null}
currentProject={currentProject ?? null}
me={me ?? null}
organizations={organizationConnection ?? null}
isCDNEnabled={isCDNEnabled ?? null}
>
{currentOrganization && currentProject && currentTarget ? (
hasCollectedOperations ? (
<OperationsView
organizationCleanId={currentOrganization.cleanId}
projectCleanId={currentProject.cleanId}
targetCleanId={currentTarget.cleanId}
/>
) : (
<div className="py-8">
<EmptyList
title="Hive is waiting for your first collected operation"
description="You can collect usage of your GraphQL API with Hive Client"
docsUrl="/features/usage-reporting"
/>
</div>
)
) : null}
</TargetLayout>
);
}
function OperationsPage(): ReactElement {
return (
<>
<Title title="Operations" />
<TargetLayout value="operations" query={TargetOperationsPageQuery}>
{({ organization, project, target, hasCollectedOperations }) =>
organization && project && target ? (
<div className="relative">
{hasCollectedOperations ? (
<OperationsView
organizationCleanId={organization.organization.cleanId}
projectCleanId={project.cleanId}
targetCleanId={target.cleanId}
/>
) : (
<EmptyList
title="Hive is waiting for your first collected operation"
description="You can collect usage of your GraphQL API with Hive Client"
docsUrl="/features/usage-reporting"
/>
)}
</div>
) : null
}
</TargetLayout>
<MetaTitle title="Operations" />
<TargetOperationsPageContent />
</>
);
}

View file

@ -5,17 +5,18 @@ import { useFormik } from 'formik';
import { useMutation, useQuery } from 'urql';
import * as Yup from 'yup';
import { authenticated } from '@/components/authenticated-container';
import { TargetLayout } from '@/components/layouts';
import { TargetLayout } from '@/components/layouts/target';
import { SchemaEditor } from '@/components/schema-editor';
import { CDNAccessTokens } from '@/components/target/settings/cdn-access-tokens';
import { Subtitle, Title } from '@/components/ui/page';
import {
Button,
Card,
Checkbox,
DocsLink,
DocsNote,
Heading,
Input,
MetaTitle,
Spinner,
Switch,
Table,
@ -23,7 +24,6 @@ import {
TBody,
Td,
TimeAgo,
Title,
Tr,
} from '@/components/v2';
import { Combobox } from '@/components/v2/combobox';
@ -32,6 +32,7 @@ import { FragmentType, graphql, useFragment } from '@/gql';
import { DeleteTokensDocument, SetTargetValidationDocument, TokensDocument } from '@/graphql';
import { canAccessTarget, TargetAccessScope } from '@/lib/access/target';
import { useRouteSelector, useToggle } from '@/lib/hooks';
import { useNotFoundRedirectOnError } from '@/lib/hooks/use-not-found-redirect-on-error';
import { withSessionProtection } from '@/lib/supertokens/guard';
const RegistryAccessTokens_MeFragment = graphql(`
@ -79,17 +80,15 @@ function RegistryAccessTokens(props: {
return (
<Card>
<Heading className="mb-2">Registry Access Tokens</Heading>
<p className="mb-3 font-light text-gray-300">
Be careful! These tokens allow to read and write your target data.
</p>
<DocsNote>
<div className="text-sm text-gray-400">
Registry Access Tokens are used to access to Hive Registry and perform actions on your
targets/projects. In most cases, this token is used from the Hive CLI.
<br />
</div>
<p>
<DocsLink href="/management/targets#registry-access-tokens">
Learn more about Registry Access Tokens
</DocsLink>
</DocsNote>
</p>
{canManage && (
<div className="my-3.5 flex justify-between">
<Button variant="primary" onClick={toggleModalOpen} size="large" className="px-5">
@ -167,14 +166,14 @@ const ExtendBaseSchema = (props: { baseSchema: string }): ReactElement => {
return (
<Card>
<Heading className="mb-2">Extend Your Schema</Heading>
<DocsNote>
<div className="text-sm text-gray-400">
Schema Extensions is pre-defined GraphQL schema that is automatically merged with your
published schemas, before being checked and validated.
<br />
<DocsLink href="/management/targets#schema-extensions">
You can find more details and examples in the documentation
</DocsLink>
</DocsNote>
</div>
<SchemaEditor
theme="vs-dark"
options={{ readOnly: mutation.fetching }}
@ -285,8 +284,8 @@ function ClientExclusion(
);
}
const Settings_TargetSettingsQuery = graphql(`
query Settings_TargetSettingsQuery(
const TargetSettingsPage_TargetSettingsQuery = graphql(`
query TargetSettingsPage_TargetSettingsQuery(
$selector: TargetSelectorInput!
$targetsSelector: ProjectSelectorInput!
$organizationSelector: OrganizationSelectorInput!
@ -320,8 +319,10 @@ const Settings_TargetSettingsQuery = graphql(`
}
`);
const Settings_UpdateTargetValidationSettingsMutation = graphql(`
mutation Settings_UpdateTargetValidationSettings($input: UpdateTargetValidationSettingsInput!) {
const TargetSettingsPage_UpdateTargetValidationSettingsMutation = graphql(`
mutation TargetSettingsPage_UpdateTargetValidationSettings(
$input: UpdateTargetValidationSettingsInput!
) {
updateTargetValidationSettings(input: $input) {
ok {
target {
@ -350,9 +351,11 @@ function floorDate(date: Date): Date {
const ConditionalBreakingChanges = (): ReactElement => {
const router = useRouteSelector();
const [targetValidation, setValidation] = useMutation(SetTargetValidationDocument);
const [mutation, updateValidation] = useMutation(Settings_UpdateTargetValidationSettingsMutation);
const [mutation, updateValidation] = useMutation(
TargetSettingsPage_UpdateTargetValidationSettingsMutation,
);
const [targetSettings] = useQuery({
query: Settings_TargetSettingsQuery,
query: TargetSettingsPage_TargetSettingsQuery,
variables: {
selector: {
organization: router.organizationId,
@ -436,11 +439,11 @@ const ConditionalBreakingChanges = (): ReactElement => {
/>
)}
</Heading>
<DocsNote>
<div className="text-sm text-gray-400">
Conditional Breaking Changes can change the behavior of schema checks, based on real
traffic data sent to Hive.{' '}
<DocsLink href="/management/targets#conditional-breaking-changes">Learn more</DocsLink>
</DocsNote>
</div>
<div
className={clsx(
'mb-3 mt-4 flex flex-col items-start gap-3 font-light text-gray-300',
@ -583,8 +586,90 @@ const ConditionalBreakingChanges = (): ReactElement => {
);
};
const Settings_UpdateTargetNameMutation = graphql(`
mutation Settings_UpdateTargetName($input: UpdateTargetNameInput!) {
function TargetName(props: {
targetName: string | null;
organizationId: string;
projectId: string;
targetId: string;
}) {
const router = useRouteSelector();
const [mutation, mutate] = useMutation(TargetSettingsPage_UpdateTargetNameMutation);
const { handleSubmit, values, handleChange, handleBlur, isSubmitting, errors, touched } =
useFormik({
enableReinitialize: true,
initialValues: {
name: props.targetName || '',
},
validationSchema: Yup.object().shape({
name: Yup.string().required('Target name is required'),
}),
onSubmit: values =>
mutate({
input: {
organization: props.organizationId,
project: props.projectId,
target: props.targetId,
name: values.name,
},
}).then(result => {
if (result?.data?.updateTargetName?.ok) {
const newTargetId = result.data.updateTargetName.ok.updatedTarget.cleanId;
void router.replace(
`/${router.organizationId}/${router.projectId}/${newTargetId}/settings`,
);
}
}),
});
return (
<Card>
<Heading className="mb-2">Target Name</Heading>
<div className="text-sm text-gray-400">
Changing the name of your target will also change the slug of your target URL, and will
invalidate any existing links to your target.
<br />
<DocsLink href="/management/targets#rename-a-target">
You can read more about it in the documentation
</DocsLink>
</div>
<form onSubmit={handleSubmit} className="flex gap-x-2">
<Input
placeholder="Target name"
name="name"
value={values.name}
onChange={handleChange}
onBlur={handleBlur}
disabled={isSubmitting}
isInvalid={touched.name && !!errors.name}
className="w-96"
/>
<Button
type="submit"
variant="primary"
size="large"
disabled={isSubmitting}
className="px-10"
>
Save
</Button>
</form>
{touched.name && (errors.name || mutation.error) && (
<div className="mt-2 text-red-500">
{errors.name ?? mutation.error?.graphQLErrors[0]?.message ?? mutation.error?.message}
</div>
)}
{mutation.data?.updateTargetName.error?.inputErrors?.name && (
<div className="mt-2 text-red-500">
{mutation.data.updateTargetName.error.inputErrors.name}
</div>
)}
</Card>
);
}
const TargetSettingsPage_UpdateTargetNameMutation = graphql(`
mutation TargetSettingsPage_UpdateTargetName($input: UpdateTargetNameInput!) {
updateTargetName(input: $input) {
ok {
selector {
@ -624,171 +709,156 @@ const TargetSettingsPage_OrganizationFragment = graphql(`
}
`);
const Page = (props: {
target: FragmentType<typeof TargetSettingsPage_TargetFragment>;
organization: FragmentType<typeof TargetSettingsPage_OrganizationFragment>;
}) => {
const target = useFragment(TargetSettingsPage_TargetFragment, props.target);
const organization = useFragment(TargetSettingsPage_OrganizationFragment, props.organization);
const router = useRouteSelector();
function TargetDelete(props: { organizationId: string; projectId: string; targetId: string }) {
const [isModalOpen, toggleModalOpen] = useToggle();
const [mutation, mutate] = useMutation(Settings_UpdateTargetNameMutation);
const { handleSubmit, values, handleChange, handleBlur, isSubmitting, errors, touched } =
useFormik({
enableReinitialize: true,
initialValues: {
name: target?.name || '',
},
validationSchema: Yup.object().shape({
name: Yup.string().required('Target name is required'),
}),
onSubmit: values =>
mutate({
input: {
organization: router.organizationId,
project: router.projectId,
target: router.targetId,
name: values.name,
},
}).then(result => {
if (result?.data?.updateTargetName?.ok) {
const newTargetId = result.data.updateTargetName.ok.updatedTarget.cleanId;
void router.replace(
`/${router.organizationId}/${router.projectId}/${newTargetId}/settings`,
);
}
}),
});
const me = organization?.me;
const canAccessTokens = canAccessTarget(TargetAccessScope.TokensRead, me);
const canDelete = canAccessTarget(TargetAccessScope.Delete, me);
return (
<>
<Card>
<Heading className="mb-2">Target Name</Heading>
<DocsNote warn>
Changing the name of your target will also change the slug of your target URL, and will
invalidate any existing links to your target.
<br />
<DocsLink href="/management/targets#rename-a-target">
You can read more about it in the documentation
</DocsLink>
</DocsNote>
<form onSubmit={handleSubmit} className="flex gap-x-2">
<Input
placeholder="Target name"
name="name"
value={values.name}
onChange={handleChange}
onBlur={handleBlur}
disabled={isSubmitting}
isInvalid={touched.name && !!errors.name}
className="w-96"
/>
<Button
type="submit"
variant="primary"
size="large"
disabled={isSubmitting}
className="px-10"
>
Save
</Button>
</form>
{touched.name && (errors.name || mutation.error) && (
<div className="mt-2 text-red-500">
{errors.name ?? mutation.error?.graphQLErrors[0]?.message ?? mutation.error?.message}
<div className="flex items-center justify-between">
<div>
<Heading className="mb-2">Delete Target</Heading>
<div className="text-sm text-gray-400">
Deleting an project also delete all schemas and data associated with it.
<br />
<DocsLink href="/management/targets#delete-a-target">
<strong>This action is not reversible!</strong> You can find more information about
this process in the documentation
</DocsLink>
</div>
</div>
)}
{mutation.data?.updateTargetName.error?.inputErrors?.name && (
<div className="mt-2 text-red-500">
{mutation.data.updateTargetName.error.inputErrors.name}
<div className="flex items-center gap-x-2">
<Button
variant="primary"
size="large"
danger
onClick={toggleModalOpen}
className="px-5"
>
Delete Target
</Button>
</div>
)}
</div>
</Card>
{canAccessTokens && <RegistryAccessTokens me={me} />}
{canAccessTokens && <CDNAccessTokens me={me} />}
<ConditionalBreakingChanges />
<ExtendBaseSchema baseSchema={target?.baseSchema ?? ''} />
{canDelete && (
<Card>
<div className="flex items-center justify-between">
<div>
<Heading className="mb-2">Delete Target</Heading>
<DocsNote warn>
Deleting an project also delete all schemas and data associated with it.
<br />
<DocsLink href="/management/targets#delete-a-target">
<strong>This action is not reversible!</strong> You can find more information
about this process in the documentation
</DocsLink>
</DocsNote>
</div>
<div className="flex items-center gap-x-2">
<Button
variant="primary"
size="large"
danger
onClick={toggleModalOpen}
className="px-5"
>
Delete Target
</Button>
</div>
</div>
</Card>
)}
<DeleteTargetModal isOpen={isModalOpen} toggleModalOpen={toggleModalOpen} />
<DeleteTargetModal
organizationId={props.organizationId}
projectId={props.projectId}
targetId={props.targetId}
isOpen={isModalOpen}
toggleModalOpen={toggleModalOpen}
/>
</>
);
};
}
const TargetSettingsPageQuery = graphql(`
query TargetSettingsPageQuery($organizationId: ID!, $projectId: ID!, $targetId: ID!) {
organizations {
...TargetLayout_OrganizationConnectionFragment
}
organization(selector: { organization: $organizationId }) {
organization {
...TargetLayout_OrganizationFragment
cleanId
...TargetLayout_CurrentOrganizationFragment
...TargetSettingsPage_OrganizationFragment
me {
...CDNAccessTokens_MeFragment
}
}
}
project(selector: { organization: $organizationId, project: $projectId }) {
...TargetLayout_ProjectFragment
}
targets(selector: { organization: $organizationId, project: $projectId }) {
...TargetLayout_TargetConnectionFragment
cleanId
...TargetLayout_CurrentProjectFragment
}
target(selector: { organization: $organizationId, project: $projectId, target: $targetId }) {
id
cleanId
name
...TargetSettingsPage_TargetFragment
}
me {
...TargetLayout_MeFragment
}
...TargetLayout_IsCDNEnabledFragment
}
`);
function TargetSettingsContent() {
const router = useRouteSelector();
const [query] = useQuery({
query: TargetSettingsPageQuery,
variables: {
organizationId: router.organizationId,
projectId: router.projectId,
targetId: router.targetId,
},
});
useNotFoundRedirectOnError(!!query.error);
const me = query.data?.me;
const currentOrganization = query.data?.organization?.organization;
const currentProject = query.data?.project;
const currentTarget = query.data?.target;
const organizationConnection = query.data?.organizations;
const isCDNEnabled = query.data;
const organizationForSettings = useFragment(
TargetSettingsPage_OrganizationFragment,
currentOrganization,
);
const targetForSettings = useFragment(TargetSettingsPage_TargetFragment, currentTarget);
const canAccessTokens = canAccessTarget(
TargetAccessScope.TokensRead,
organizationForSettings?.me ?? null,
);
const canDelete = canAccessTarget(TargetAccessScope.Delete, organizationForSettings?.me ?? null);
if (query.error) {
return null;
}
return (
<TargetLayout
value="settings"
currentOrganization={currentOrganization ?? null}
currentProject={currentProject ?? null}
me={me ?? null}
organizations={organizationConnection ?? null}
isCDNEnabled={isCDNEnabled ?? null}
>
<div className="py-6">
<Title>Settings</Title>
<Subtitle>Manage your target settings.</Subtitle>
</div>
{currentOrganization && currentProject && currentTarget && organizationForSettings ? (
<div className="flex flex-col gap-y-4">
<TargetName
targetName={currentTarget.name}
targetId={currentTarget.cleanId}
projectId={currentProject.cleanId}
organizationId={currentOrganization.cleanId}
/>
{canAccessTokens && <RegistryAccessTokens me={organizationForSettings.me} />}
{canAccessTokens && <CDNAccessTokens me={organizationForSettings.me} />}
<ConditionalBreakingChanges />
<ExtendBaseSchema baseSchema={targetForSettings?.baseSchema ?? ''} />
{canDelete && (
<TargetDelete
targetId={currentTarget.cleanId}
projectId={currentProject.cleanId}
organizationId={currentOrganization.cleanId}
/>
)}
</div>
) : null}
</TargetLayout>
);
}
function SettingsPage(): ReactElement {
return (
<>
<Title title="Settings" />
<TargetLayout
value="settings"
className="flex flex-col gap-16"
query={TargetSettingsPageQuery}
>
{props =>
props.organization ? (
<Page target={props.target!} organization={props.organization.organization} />
) : null
}
</TargetLayout>
<MetaTitle title="Settings" />
<TargetSettingsContent />
</>
);
}

View file

@ -1,154 +1,369 @@
import { ReactElement } from 'react';
import { ReactElement, useMemo, useRef } from 'react';
import NextLink from 'next/link';
import clsx from 'clsx';
import { formatISO, subDays } from 'date-fns';
import * as echarts from 'echarts';
import ReactECharts from 'echarts-for-react';
import { Globe, History } from 'lucide-react';
import AutoSizer from 'react-virtualized-auto-sizer';
import { useQuery } from 'urql';
import { authenticated } from '@/components/authenticated-container';
import { ProjectLayout } from '@/components/layouts';
import { ProjectLayout } from '@/components/layouts/project';
import {
Activities,
Badge,
Button,
Card,
EmptyList,
Heading,
TimeAgo,
Title,
} from '@/components/v2';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/v2/dropdown';
import { LinkIcon, MoreIcon, SettingsIcon } from '@/components/v2/icon';
import { graphql } from '@/gql';
import { TargetQuery, TargetsDocument, VersionsDocument } from '@/graphql';
import { useClipboard } from '@/lib/hooks/use-clipboard';
createEmptySeries,
fullSeries,
resolutionToMilliseconds,
} from '@/components/target/operations/utils';
import { Subtitle, Title } from '@/components/ui/page';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { Activities, Card, EmptyList, MetaTitle } from '@/components/v2';
import { FragmentType, graphql, useFragment } from '@/gql';
import { useFormattedNumber } from '@/lib/hooks';
import { useNotFoundRedirectOnError } from '@/lib/hooks/use-not-found-redirect-on-error';
import { useRouteSelector } from '@/lib/hooks/use-route-selector';
import { withSessionProtection } from '@/lib/supertokens/guard';
import { cn, pluralize } from '@/lib/utils';
const TargetCard = ({
target,
}: {
target: Exclude<TargetQuery['target'], null | undefined>;
function floorDate(date: Date): Date {
const time = 1000 * 60;
return new Date(Math.floor(date.getTime() / time) * time);
}
const TargetCard_TargetFragment = graphql(`
fragment TargetCard_TargetFragment on Target {
id
name
cleanId
}
`);
const TargetCard = (props: {
target: FragmentType<typeof TargetCard_TargetFragment> | null;
highestNumberOfRequests: number;
period: {
from: string;
to: string;
};
requestsOverTime: { date: string; value: number }[] | null;
schemaVersionsCount: number | null;
days: number;
}): ReactElement => {
const router = useRouteSelector();
const copyToClipboard = useClipboard();
const [versionsQuery] = useQuery({
query: VersionsDocument,
variables: {
selector: {
organization: router.organizationId,
project: router.projectId,
target: target.cleanId,
},
limit: 1,
},
requestPolicy: 'cache-and-network',
});
const versions = versionsQuery.data?.schemaVersions;
const lastVersion = versions?.nodes[0];
const author = lastVersion?.log && 'author' in lastVersion.log ? lastVersion.log.author : null;
const isValid = lastVersion?.valid;
const href = `/${router.organizationId}/${router.projectId}/${target.cleanId}`;
const target = useFragment(TargetCard_TargetFragment, props.target);
const href = target ? `/${router.organizationId}/${router.projectId}/${target.cleanId}` : '';
const { period, highestNumberOfRequests } = props;
const interval = resolutionToMilliseconds(props.days, period);
const requests = useMemo(() => {
if (props.requestsOverTime?.length) {
return fullSeries(
props.requestsOverTime.map<[string, number]>(node => [node.date, node.value]),
interval,
props.period,
);
}
return createEmptySeries({ interval, period });
}, [interval]);
const totalNumberOfRequests = useMemo(
() => requests.reduce((acc, [_, value]) => acc + value, 0),
[requests],
);
const totalNumberOfVersions = props.schemaVersionsCount ?? 0;
const requestsInDateRange = useFormattedNumber(totalNumberOfRequests);
const schemaVersionsInDateRange = useFormattedNumber(totalNumberOfVersions);
return (
<Card as={NextLink} key={target.id} className="hover:bg-gray-800/40" href={href}>
<div className="flex items-start justify-between gap-2">
<div>
<h2 className="line-clamp-2 text-lg font-bold">{target.name}</h2>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button>
<MoreIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent sideOffset={5} align="start">
<DropdownMenuItem
onClick={async e => {
e.stopPropagation();
await copyToClipboard(`${location.origin}${href}`);
}}
>
<LinkIcon />
Share Link
</DropdownMenuItem>
<NextLink
href={`/${router.organizationId}/${router.projectId}/${target.cleanId}#settings`}
>
<DropdownMenuItem>
<SettingsIcon />
Settings
</DropdownMenuItem>
</NextLink>
</DropdownMenuContent>
</DropdownMenu>
</div>
{author && (
<>
<div className={clsx('mt-2.5 mb-1.5 flex items-center gap-x-2 text-sm text-gray-500')}>
{lastVersion ? (
<>
<Badge color={isValid ? 'green' : 'red'} />
<span>
{'commit' in lastVersion.log ? lastVersion.log.commit.substring(0, 7) : ''}
</span>
<span>
- Published <TimeAgo date={lastVersion.date} />
</span>
</>
) : (
<Badge color="yellow" />
)}
<Card
as={NextLink}
href={href}
className="h-full pt-4 px-0 self-start hover:bg-gray-800/40 hover:shadow-md hover:shadow-gray-800/50"
>
<TooltipProvider>
<div className="flex items-start gap-x-2">
<div className="grow">
<div>
<AutoSizer disableHeight>
{size => (
<ReactECharts
style={{ width: size.width, height: 90 }}
option={{
animation: !!target,
color: ['#f4b740'],
grid: {
left: 0,
top: 10,
right: 0,
bottom: 10,
},
tooltip: {
trigger: 'axis',
axisPointer: {
label: {
formatter({ value }: { value: number }) {
return new Date(value).toDateString();
},
},
},
},
xAxis: [
{
show: false,
type: 'time',
boundaryGap: false,
},
],
yAxis: [
{
show: false,
type: 'value',
min: 0,
max: highestNumberOfRequests,
},
],
series: [
{
name: 'Requests',
type: 'line',
smooth: false,
lineStyle: {
width: 2,
},
showSymbol: false,
areaStyle: {
opacity: 0.8,
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: 'rgba(244, 184, 64, 0.20)',
},
{
offset: 1,
color: 'rgba(244, 184, 64, 0)',
},
]),
},
emphasis: {
focus: 'series',
},
data: requests,
},
],
}}
/>
)}
</AutoSizer>
</div>
<div className="flex flex-row gap-y-3 px-4 pt-4 justify-between items-center">
<div>
{target ? (
<h4 className="line-clamp-2 text-lg font-bold">{target.name}</h4>
) : (
<div className="w-48 h-4 py-2 bg-gray-800 rounded-full animate-pulse" />
)}
</div>
<div className="flex flex-col gap-y-2 py-1">
{target ? (
<>
<Tooltip>
<TooltipTrigger>
<div className="flex flex-row gap-x-2 items-center">
<Globe className="w-4 h-4 text-gray-500" />
<div className="text-xs">
{requestsInDateRange}{' '}
{pluralize(totalNumberOfRequests, 'request', 'requests')}
</div>
</div>
</TooltipTrigger>
<TooltipContent>
Number of GraphQL requests in the last {props.days} days.
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger>
<div className="flex flex-row gap-x-2 items-center">
<History className="w-4 h-4 text-gray-500" />
<div className="text-xs">
{schemaVersionsInDateRange}{' '}
{pluralize(totalNumberOfVersions, 'commit', 'commits')}
</div>
</div>
</TooltipTrigger>
<TooltipContent>
Number of schemas pushed to this project in the last {props.days} days.
</TooltipContent>
</Tooltip>
</>
) : (
<>
<div className="w-16 h-2 my-1 bg-gray-800 rounded-full animate-pulse" />
<div className="w-16 h-2 my-1 bg-gray-800 rounded-full animate-pulse" />
</>
)}
</div>
</div>
</div>
</>
)}
</div>
</TooltipProvider>
</Card>
);
};
const Page = () => {
const router = useRouteSelector();
const [targetsQuery] = useQuery({
query: TargetsDocument,
const period = useRef<{
from: string;
to: string;
}>();
const days = 14;
if (!period.current) {
const now = floorDate(new Date());
const from = formatISO(subDays(now, days));
const to = formatISO(now);
period.current = { from, to };
}
const [query] = useQuery({
query: ProjectOverviewPageQuery,
variables: {
selector: {
organization: router.organizationId,
project: router.projectId,
},
organizationId: router.organizationId,
projectId: router.projectId,
chartResolution: days, // 14 days = 14 data points
period: period.current,
},
});
const targets = targetsQuery.data?.targets;
useNotFoundRedirectOnError(!!query.error);
const me = query.data?.me;
const currentOrganization = query.data?.organization?.organization;
const currentProject = query.data?.project;
const organizationConnection = query.data?.organizations;
const targetConnection = query.data?.targets;
const targets = targetConnection?.nodes;
const highestNumberOfRequests = useMemo(() => {
if (targets?.length) {
return targets.reduce((max, target) => {
return Math.max(
max,
target.requestsOverTime.reduce((max, { value }) => Math.max(max, value), 0),
);
}, 100);
}
return 100;
}, [targets]);
if (query.error) {
return null;
}
return (
<>
<div className="flex grow flex-col gap-4">
<Heading>List of targets</Heading>
{targets && targets.total === 0 ? (
<EmptyList
title="Hive is waiting for your first target"
description='You can create a target by clicking the "New Target" button'
docsUrl="/management/targets#create-a-new-target"
/>
) : (
targets?.nodes.map(target => <TargetCard key={target.id} target={target} />)
)}
<ProjectLayout
value="targets"
className="flex justify-between gap-12"
currentOrganization={currentOrganization ?? null}
currentProject={currentProject ?? null}
me={me ?? null}
organizations={organizationConnection ?? null}
>
<div className="grow">
<div className="py-6">
<Title>Targets</Title>
<Subtitle>A list of available targets in your project.</Subtitle>
</div>
<div
className={cn(
'grow',
targets?.length === 0 ? '' : 'grid grid-cols-2 gap-5 items-stretch',
)}
>
{targets ? (
targets.length === 0 ? (
<EmptyList
title="Hive is waiting for your first target"
description='You can create a target by clicking the "New Target" button'
docsUrl="/management/targets#create-a-new-target"
/>
) : (
targets
.sort((a, b) => {
const diff = b.schemaVersionsCount - a.schemaVersionsCount;
if (diff !== 0) {
return diff;
}
return a.name.localeCompare(b.name);
})
.map(target => (
<TargetCard
key={target.id}
target={target}
days={days}
highestNumberOfRequests={highestNumberOfRequests}
period={period.current!}
requestsOverTime={target.requestsOverTime}
schemaVersionsCount={target.schemaVersionsCount}
/>
))
)
) : (
<>
{Array.from({ length: 4 }).map((_, index) => (
<TargetCard
key={index}
target={null}
days={days}
highestNumberOfRequests={highestNumberOfRequests}
period={period.current!}
requestsOverTime={null}
schemaVersionsCount={null}
/>
))}
</>
)}
</div>
</div>
<Activities />
</>
</ProjectLayout>
);
};
const ProjectOverviewPageQuery = graphql(`
query ProjectOverviewPageQuery($organizationId: ID!, $projectId: ID!) {
query ProjectOverviewPageQuery(
$organizationId: ID!
$projectId: ID!
$chartResolution: Int!
$period: DateRangeInput!
) {
organization(selector: { organization: $organizationId }) {
organization {
...ProjectLayout_OrganizationFragment
...ProjectLayout_CurrentOrganizationFragment
}
}
project(selector: { organization: $organizationId, project: $projectId }) {
...ProjectLayout_ProjectFragment
...ProjectLayout_CurrentProjectFragment
}
organizations {
...ProjectLayout_OrganizationConnectionFragment
}
targets(selector: { organization: $organizationId, project: $projectId }) {
total
nodes {
id
name
...TargetCard_TargetFragment
requestsOverTime(resolution: $chartResolution, period: $period) {
date
value
}
schemaVersionsCount(period: $period)
}
}
me {
...ProjectLayout_MeFragment
}
}
`);
@ -156,10 +371,8 @@ const ProjectOverviewPageQuery = graphql(`
function ProjectsPage(): ReactElement {
return (
<>
<Title title="Targets" />
<ProjectLayout value="targets" className="flex gap-x-5" query={ProjectOverviewPageQuery}>
{() => <Page />}
</ProjectLayout>
<MetaTitle title="Targets" />
<Page />
</>
);
}

View file

@ -1,124 +1,162 @@
import { ReactElement, useState } from 'react';
import { useMutation, useQuery } from 'urql';
import { useQuery } from 'urql';
import { authenticated } from '@/components/authenticated-container';
import { ProjectLayout } from '@/components/layouts';
import { ProjectLayout } from '@/components/layouts/project';
import { AlertsTable, AlertsTable_AlertFragment } from '@/components/project/alerts/alerts-table';
import {
ChannelsTable,
ChannelsTable_AlertChannelFragment,
} from '@/components/project/alerts/channels-table';
import {
CreateAlertModal,
CreateAlertModal_AlertChannelFragment,
CreateAlertModal_TargetFragment,
} from '@/components/project/alerts/create-alert';
import { CreateChannelModal } from '@/components/project/alerts/create-channel';
import { DeleteAlertsButton } from '@/components/project/alerts/delete-alerts-button';
import { DeleteChannelsButton } from '@/components/project/alerts/delete-channels-button';
import { Button } from '@/components/ui/button';
import {
Button,
Card,
Checkbox,
DocsLink,
DocsNote,
Heading,
Table,
Tag,
TBody,
Td,
Title,
Tr,
} from '@/components/v2';
import { CreateAlertModal, CreateChannelModal } from '@/components/v2/modals';
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Subtitle, Title } from '@/components/ui/page';
import { DocsLink, MetaTitle } from '@/components/v2';
import { FragmentType, graphql, useFragment } from '@/gql';
import {
AlertChannelsDocument,
AlertChannelType,
AlertsDocument,
DeleteAlertChannelsDocument,
DeleteAlertsDocument,
} from '@/graphql';
import { ProjectAccessScope, useProjectAccess } from '@/lib/access/project';
import { useRouteSelector, useToggle } from '@/lib/hooks';
import { useNotFoundRedirectOnError } from '@/lib/hooks/use-not-found-redirect-on-error';
import { withSessionProtection } from '@/lib/supertokens/guard';
function Channels(): ReactElement {
function Channels(props: {
channels: FragmentType<typeof ChannelsTable_AlertChannelFragment>[];
}): ReactElement {
const router = useRouteSelector();
const [checked, setChecked] = useState<string[]>([]);
const [channelAlertsQuery] = useQuery({
query: AlertChannelsDocument,
variables: {
selector: {
organization: router.organizationId,
project: router.projectId,
},
},
requestPolicy: 'cache-and-network',
});
const [selected, setSelected] = useState<string[]>([]);
const [isModalOpen, toggleModalOpen] = useToggle();
const [mutation, mutate] = useMutation(DeleteAlertChannelsDocument);
const channelAlerts = channelAlertsQuery.data?.alertChannels || [];
const channels = props.channels ?? [];
return (
<Card>
<Heading className="mb-2">Available Channels</Heading>
<DocsNote>
Alert Channels are a way to configure <strong>how</strong> you want to receive alerts and
notifications from Hive.{' '}
<DocsLink href="/management/projects#alert-channels">Learn more</DocsLink>
</DocsNote>
<Table>
<TBody>
{channelAlerts.map(channelAlert => (
<Tr key={channelAlert.id}>
<Td width="1">
<Checkbox
onCheckedChange={isChecked =>
setChecked(
isChecked
? [...checked, channelAlert.id]
: checked.filter(k => k !== channelAlert.id),
)
}
checked={checked.includes(channelAlert.id)}
/>
</Td>
<Td>{channelAlert.name}</Td>
<Td className="text-xs truncate text-gray-400">
{channelAlert.__typename === 'AlertSlackChannel'
? channelAlert.channel
: channelAlert.__typename === 'AlertWebhookChannel'
? channelAlert.endpoint
: ''}
</Td>
<Td>
<Tag color={channelAlert.type === AlertChannelType.Webhook ? 'green' : 'yellow'}>
{channelAlert.type}
</Tag>
</Td>
</Tr>
))}
</TBody>
</Table>
<div className="mt-4 flex gap-x-2">
<Button size="large" variant="primary" onClick={toggleModalOpen}>
Add channel
</Button>
{channelAlerts.length > 0 && (
<Button
size="large"
danger
disabled={checked.length === 0 || mutation.fetching}
onClick={async () => {
await mutate({
input: {
organization: router.organizationId,
project: router.projectId,
channels: checked,
},
});
setChecked([]);
}}
<CardHeader>
<CardTitle>Channels</CardTitle>
<CardDescription>
Alert Channels are a way to configure <strong>how</strong> you want to receive alerts and
notifications from Hive.
<br />
<DocsLink
className="text-muted-foreground text-sm"
href="/management/projects#alert-channels"
>
Delete {checked.length || null}
Learn more
</DocsLink>
</CardDescription>
</CardHeader>
<CardContent>
<ChannelsTable
channels={channels}
isChecked={channelId => selected.includes(channelId)}
onCheckedChange={(channelId, isChecked) => {
setSelected(
isChecked ? [...selected, channelId] : selected.filter(k => k !== channelId),
);
}}
/>
</CardContent>
<CardFooter>
<div className="mt-4 flex gap-x-2">
<Button variant="default" onClick={toggleModalOpen}>
Add channel
</Button>
)}
</div>
{channels.length > 0 && (
<DeleteChannelsButton
organizationId={router.organizationId}
projectId={router.projectId}
selected={selected}
onSuccess={() => {
setSelected([]);
}}
/>
)}
</div>
</CardFooter>
{isModalOpen && <CreateChannelModal isOpen={isModalOpen} toggleModalOpen={toggleModalOpen} />}
</Card>
);
}
const AlertsPage_OrganizationFragment = graphql(`
fragment AlertsPage_OrganizationFragment on Organization {
export function Alerts(props: {
alerts: FragmentType<typeof AlertsTable_AlertFragment>[];
channels: FragmentType<typeof CreateAlertModal_AlertChannelFragment>[];
targets: FragmentType<typeof CreateAlertModal_TargetFragment>[];
}) {
const [selected, setSelected] = useState<string[]>([]);
const router = useRouteSelector();
const [isModalOpen, toggleModalOpen] = useToggle();
const alerts = props.alerts ?? [];
console.log('isModalOpen', isModalOpen);
return (
<>
<Card>
<CardHeader>
<CardTitle>Alerts and Notifications</CardTitle>
<CardDescription>
Alerts are a way to configure <strong>when</strong> you want to receive alerts and
notifications from Hive.
<br />
<DocsLink
className="text-muted-foreground text-sm"
href="/management/projects#alerts-and-notifications-1"
>
Learn more
</DocsLink>
</CardDescription>
</CardHeader>
<CardContent>
<AlertsTable
alerts={alerts}
isChecked={alertId => selected.includes(alertId)}
onCheckedChange={(alertId, isChecked) => {
setSelected(isChecked ? [...selected, alertId] : selected.filter(k => k !== alertId));
}}
/>
</CardContent>
<CardFooter>
<div className="flex gap-x-2">
<Button variant="default" onClick={toggleModalOpen}>
Create alert
</Button>
<DeleteAlertsButton
organizationId={router.organizationId}
projectId={router.projectId}
selected={selected}
onSuccess={() => {
setSelected([]);
}}
/>
</div>
</CardFooter>
</Card>
{isModalOpen && (
<CreateAlertModal
targets={props.targets}
channels={props.channels}
isOpen={isModalOpen}
toggleModalOpen={toggleModalOpen}
/>
)}
</>
);
}
const ProjectAlertsPage_OrganizationFragment = graphql(`
fragment ProjectAlertsPage_OrganizationFragment on Organization {
cleanId
me {
...CanAccessProject_MemberFragment
@ -126,130 +164,104 @@ const AlertsPage_OrganizationFragment = graphql(`
}
`);
const AlertsPage_ProjectFragment = graphql(`
fragment AlertsPage_ProjectFragment on Project {
cleanId
}
`);
const Page = (props: {
organization: FragmentType<typeof AlertsPage_OrganizationFragment>;
project: FragmentType<typeof AlertsPage_ProjectFragment>;
}) => {
const organization = useFragment(AlertsPage_OrganizationFragment, props.organization);
const project = useFragment(AlertsPage_ProjectFragment, props.project);
useProjectAccess({
scope: ProjectAccessScope.Alerts,
member: organization.me,
redirect: true,
});
const [checked, setChecked] = useState<string[]>([]);
const router = useRouteSelector();
const [isModalOpen, toggleModalOpen] = useToggle();
const [mutation, mutate] = useMutation(DeleteAlertsDocument);
const [alertsQuery] = useQuery({
query: AlertsDocument,
variables: {
selector: {
organization: organization.cleanId,
project: project.cleanId,
},
},
requestPolicy: 'cache-and-network',
});
const alerts = alertsQuery.data?.alerts || [];
return (
<>
<Channels />
<Card>
<Heading className="mb-2">Active Alerts and Notifications</Heading>
<DocsNote>
Alerts are a way to configure <strong>when</strong> you want to receive alerts and
notifications from Hive.{' '}
<DocsLink href="/management/projects#alerts-and-notifications-1">Learn more</DocsLink>
</DocsNote>
<Table>
<TBody>
{alerts.map(alert => (
<Tr key={alert.id}>
<Td width="1">
<Checkbox
onCheckedChange={isChecked =>
setChecked(
isChecked ? [...checked, alert.id] : checked.filter(k => k !== alert.id),
)
}
checked={checked.includes(alert.id)}
/>
</Td>
<Td>
<span className="capitalize">
{alert.type.replaceAll('_', ' ').toLowerCase()}
</span>
</Td>
<Td>Channel: {alert.channel.name}</Td>
<Td>Target: {alert.target.name}</Td>
</Tr>
))}
</TBody>
</Table>
<div className="mt-4 flex gap-x-2">
<Button size="large" variant="primary" onClick={toggleModalOpen}>
Create alert
</Button>
{alerts.length > 0 && (
<Button
size="large"
danger
disabled={checked.length === 0 || mutation.fetching}
onClick={async () => {
await mutate({
input: {
organization: router.organizationId,
project: router.projectId,
alerts: checked,
},
});
setChecked([]);
}}
>
Delete {checked.length || null}
</Button>
)}
</div>
</Card>
{isModalOpen && <CreateAlertModal isOpen={isModalOpen} toggleModalOpen={toggleModalOpen} />}
</>
);
};
const ProjectAlertsPageQuery = graphql(`
query ProjectAlertsPageQuery($organizationId: ID!, $projectId: ID!) {
organization(selector: { organization: $organizationId }) {
organization {
...ProjectLayout_OrganizationFragment
...AlertsPage_OrganizationFragment
...ProjectLayout_CurrentOrganizationFragment
...ProjectAlertsPage_OrganizationFragment
}
}
project(selector: { organization: $organizationId, project: $projectId }) {
...ProjectLayout_ProjectFragment
...AlertsPage_ProjectFragment
...ProjectLayout_CurrentProjectFragment
targets {
nodes {
...CreateAlertModal_TargetFragment
}
}
alerts {
...AlertsTable_AlertFragment
}
alertChannels {
...ChannelsTable_AlertChannelFragment
...CreateAlertModal_AlertChannelFragment
}
}
organizations {
...ProjectLayout_OrganizationConnectionFragment
}
me {
...ProjectLayout_MeFragment
}
}
`);
function AlertsPageContent() {
const router = useRouteSelector();
const [query] = useQuery({
query: ProjectAlertsPageQuery,
variables: {
organizationId: router.organizationId,
projectId: router.projectId,
},
requestPolicy: 'cache-and-network',
});
useNotFoundRedirectOnError(!!query.error);
const me = query.data?.me;
const currentOrganization = query.data?.organization?.organization;
const currentProject = query.data?.project;
const organizationConnection = query.data?.organizations;
const organizationForAlerts = useFragment(
ProjectAlertsPage_OrganizationFragment,
currentOrganization,
);
useProjectAccess({
scope: ProjectAccessScope.Alerts,
member: organizationForAlerts?.me ?? null,
redirect: true,
});
if (query.error) {
return null;
}
const alerts = currentProject?.alerts || [];
const channels = currentProject?.alertChannels || [];
const targets = currentProject?.targets?.nodes || [];
return (
<ProjectLayout
currentOrganization={currentOrganization ?? null}
currentProject={currentProject ?? null}
organizations={organizationConnection ?? null}
me={me ?? null}
value="alerts"
className="flex flex-col gap-y-10"
>
<div>
<div className="py-6">
<Title>Alerts and Notifications</Title>
<Subtitle>Configure alerts and notifications for your project.</Subtitle>
</div>
{currentProject && currentOrganization ? (
<div className="flex flex-col gap-y-4">
<Channels channels={channels} />
<Alerts alerts={alerts} channels={channels} targets={targets} />
</div>
) : null}
</div>
</ProjectLayout>
);
}
function AlertsPage(): ReactElement {
return (
<>
<Title title="Alerts" />
<ProjectLayout
value="alerts"
className="flex flex-col gap-y-10"
query={ProjectAlertsPageQuery}
>
{props => <Page organization={props.organization.organization} project={props.project} />}
</ProjectLayout>
<MetaTitle title="Alerts" />
<AlertsPageContent />
</>
);
}

View file

@ -1,11 +1,17 @@
import { ReactElement } from 'react';
import { useMutation } from 'urql';
import { useMutation, useQuery } from 'urql';
import { authenticated } from '@/components/authenticated-container';
import { ProjectLayout } from '@/components/layouts';
import { ProjectLayout } from '@/components/layouts/project';
import { PolicySettings } from '@/components/policy/policy-settings';
import { Card, DocsLink, DocsNote, Heading, Title } from '@/components/v2';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Subtitle, Title } from '@/components/ui/page';
import { DocsLink, MetaTitle } from '@/components/v2';
import { graphql } from '@/gql';
import { ProjectAccessScope } from '@/gql/graphql';
import { RegistryModel } from '@/graphql';
import { useProjectAccess } from '@/lib/access/project';
import { useRouteSelector } from '@/lib/hooks';
import { useNotFoundRedirectOnError } from '@/lib/hooks/use-not-found-redirect-on-error';
import { withSessionProtection } from '@/lib/supertokens/guard';
const ProjectPolicyPageQuery = graphql(`
@ -23,12 +29,15 @@ const ProjectPolicyPageQuery = graphql(`
}
}
}
...ProjectLayout_OrganizationFragment
me {
...CanAccessProject_MemberFragment
}
...ProjectLayout_CurrentOrganizationFragment
}
}
project(selector: { organization: $organizationId, project: $projectId }) {
id
...ProjectLayout_ProjectFragment
...ProjectLayout_CurrentProjectFragment
registryModel
schemaPolicy {
id
@ -36,6 +45,12 @@ const ProjectPolicyPageQuery = graphql(`
...PolicySettings_SchemaPolicyFragment
}
}
organizations {
...ProjectLayout_OrganizationConnectionFragment
}
me {
...ProjectLayout_MeFragment
}
}
`);
@ -51,7 +66,7 @@ const UpdateSchemaPolicyForProject = graphql(`
ok {
project {
id
...ProjectLayout_ProjectFragment
...ProjectLayout_CurrentProjectFragment
schemaPolicy {
id
updatedAt
@ -63,23 +78,60 @@ const UpdateSchemaPolicyForProject = graphql(`
}
`);
function ProjectPolicyPage(): ReactElement {
function ProjectPolicyContent() {
const [mutation, mutate] = useMutation(UpdateSchemaPolicyForProject);
const router = useRouteSelector();
const [query] = useQuery({
query: ProjectPolicyPageQuery,
variables: {
organizationId: router.organizationId,
projectId: router.projectId,
},
requestPolicy: 'cache-and-network',
});
useNotFoundRedirectOnError(!!query.error);
const me = query.data?.me;
const currentOrganization = query.data?.organization?.organization;
const currentProject = query.data?.project;
const organizationConnection = query.data?.organizations;
useProjectAccess({
scope: ProjectAccessScope.Settings,
member: currentOrganization?.me ?? null,
redirect: true,
});
if (query.error) {
return null;
}
const isLegacyProject = currentProject?.registryModel === RegistryModel.Legacy;
return (
<>
<Title title="Project Schema Policy" />
<ProjectLayout
value="policy"
className="flex flex-col gap-y-10"
query={ProjectPolicyPageQuery}
>
{(props, selector) => {
return props.project && props.organization ? (
<Card>
<Heading className="mb-2">Project Schema Policy</Heading>
{props.project.registryModel === RegistryModel.Legacy ? (
<DocsNote warn>
<ProjectLayout
currentOrganization={currentOrganization ?? null}
currentProject={currentProject ?? null}
organizations={organizationConnection ?? null}
me={me ?? null}
value="policy"
className="flex flex-col gap-y-10"
>
<div>
<div className="py-6">
<Title>Organization Schema Policy</Title>
<Subtitle>
Schema Policies enable developers to define additional semantic checks on the GraphQL
schema.
</Subtitle>
</div>
{currentProject && currentOrganization ? (
<Card>
<CardHeader>
<CardTitle>Rules</CardTitle>
{currentProject && isLegacyProject ? (
<CardDescription>
<strong>
Policy feature is only available for projects that are using the new registry
model.
@ -88,51 +140,68 @@ function ProjectPolicyPage(): ReactElement {
policy feature.
</strong>
<br />
<DocsLink href="https://the-guild.dev/blog/graphql-hive-improvements-in-schema-registry">
<DocsLink
className="text-muted-foreground text-sm"
href="https://the-guild.dev/blog/graphql-hive-improvements-in-schema-registry"
>
Learn more
</DocsLink>
</DocsNote>
</CardDescription>
) : (
<>
<DocsNote>
<strong>Schema Policies</strong> enable developers to define additional semantic
checks on the GraphQL schema. At the project level, policies can be defined to
affect all targets, and override policy configuration defined at the
organization level.{' '}
<DocsLink href="/features/schema-policy">Learn more</DocsLink>
</DocsNote>
{props.organization.organization.schemaPolicy === null ||
props.organization.organization.schemaPolicy?.allowOverrides ? (
<PolicySettings
saving={mutation.fetching}
rulesInParent={props.organization.organization.schemaPolicy?.rules.map(
r => r.rule.id,
)}
error={
mutation.error?.message ||
mutation.data?.updateSchemaPolicyForProject.error?.message
}
onSave={async newPolicy => {
await mutate({
selector,
policy: newPolicy,
});
}}
currentState={props.project.schemaPolicy}
/>
) : (
<div className="mt-4 text-gray-400 pl-1 text-sm font-bold">
<p className="text-orange-500 inline-block mr-4">!</p>
Organization settings does not allow projects to override policy. Please
consult your organization administrator.
</div>
)}
</>
<CardDescription>
At the project level, policies can be defined to affect all targets, and override
policy configuration defined at the organization level.
<br />
<DocsLink
href="/features/schema-policy"
className="text-muted-foreground text-sm"
>
Learn more
</DocsLink>
</CardDescription>
)}
</Card>
) : null;
}}
</ProjectLayout>
</CardHeader>
<CardContent>
{currentOrganization.schemaPolicy === null ||
currentOrganization.schemaPolicy?.allowOverrides ? (
<PolicySettings
saving={mutation.fetching}
rulesInParent={currentOrganization.schemaPolicy?.rules.map(r => r.rule.id)}
error={
mutation.error?.message ||
mutation.data?.updateSchemaPolicyForProject.error?.message
}
onSave={async newPolicy => {
await mutate({
selector: {
organization: router.organizationId,
project: router.projectId,
},
policy: newPolicy,
});
}}
currentState={currentProject.schemaPolicy}
/>
) : (
<div className="text-gray-400 pl-1 text-sm font-bold">
<p className="text-orange-500 inline-block mr-4">!</p>
Organization settings does not allow projects to override policy. Please consult
your organization administrator.
</div>
)}
</CardContent>
</Card>
) : null}
</div>
</ProjectLayout>
);
}
function ProjectPolicyPage(): ReactElement {
return (
<>
<MetaTitle title="Project Schema Policy" />
<ProjectPolicyContent />
</>
);
}

View file

@ -3,30 +3,32 @@ import { useFormik } from 'formik';
import { useMutation, useQuery } from 'urql';
import * as Yup from 'yup';
import { authenticated } from '@/components/authenticated-container';
import { ProjectLayout } from '@/components/layouts';
import { ProjectLayout } from '@/components/layouts/project';
import { ExternalCompositionSettings } from '@/components/project/settings/external-composition';
import { ModelMigrationSettings } from '@/components/project/settings/model-migration';
import { Button } from '@/components/ui/button';
import {
Button,
Card,
DocsLink,
DocsNote,
Heading,
Input,
Link,
Select,
Tag,
Title,
} from '@/components/v2';
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Subtitle, Title } from '@/components/ui/page';
import { DocsLink, Input, Link, MetaTitle, Select, Tag } from '@/components/v2';
import { DeleteProjectModal } from '@/components/v2/modals';
import { FragmentType, graphql, useFragment } from '@/gql';
import { graphql, useFragment } from '@/gql';
import { GetGitHubIntegrationDetailsDocument, ProjectType } from '@/graphql';
import { canAccessProject, ProjectAccessScope, useProjectAccess } from '@/lib/access/project';
import { useRouteSelector, useToggle } from '@/lib/hooks';
import { useNotFoundRedirectOnError } from '@/lib/hooks/use-not-found-redirect-on-error';
import { withSessionProtection } from '@/lib/supertokens/guard';
const Settings_UpdateProjectGitRepositoryMutation = graphql(`
mutation Settings_UpdateProjectGitRepository($input: UpdateProjectGitRepositoryInput!) {
const ProjectSettingsPage_UpdateProjectGitRepositoryMutation = graphql(`
mutation ProjectSettingsPage_UpdateProjectGitRepository(
$input: UpdateProjectGitRepositoryInput!
) {
updateProjectGitRepository(input: $input) {
ok {
selector {
@ -59,7 +61,7 @@ function GitHubIntegration({
},
});
const [mutation, mutate] = useMutation(Settings_UpdateProjectGitRepositoryMutation);
const [mutation, mutate] = useMutation(ProjectSettingsPage_UpdateProjectGitRepositoryMutation);
const { handleSubmit, values, handleChange, handleBlur, isSubmitting, errors, touched } =
useFormik({
enableReinitialize: true,
@ -83,62 +85,78 @@ function GitHubIntegration({
return null;
}
if (integrationQuery.data?.gitHubIntegration) {
return (
<>
<form className="flex gap-x-2" onSubmit={handleSubmit}>
<Select
name="gitRepository"
placeholder="None"
className="w-96"
options={integrationQuery.data.gitHubIntegration.repositories.map(repo => ({
name: repo.nameWithOwner,
value: repo.nameWithOwner,
}))}
value={values.gitRepository ?? undefined}
onChange={handleChange}
onBlur={handleBlur}
isInvalid={!!(touched.gitRepository && errors.gitRepository)}
/>
<Button
type="submit"
variant="primary"
size="large"
className="px-10"
disabled={isSubmitting}
>
Save
</Button>
</form>
{touched.gitRepository && (errors.gitRepository || mutation.error) && (
<div className="mt-2 text-red-500">
{errors.gitRepository ??
mutation.error?.graphQLErrors[0]?.message ??
mutation.error?.message}
</div>
)}
{mutation.data?.updateProjectGitRepository.error && (
<div className="mt-2 text-red-500">
{mutation.data.updateProjectGitRepository.error.message}
</div>
)}
</>
);
}
const githubIntegration = integrationQuery.data?.gitHubIntegration;
return (
<Tag className="!p-4">
The organization is not connected to our GitHub Application.
<Link variant="primary" href={`/${router.organizationId}#settings`}>
Visit settings
</Link>
to configure it.
</Tag>
<form onSubmit={handleSubmit}>
<Card>
<CardHeader>
<CardTitle>Git Repository</CardTitle>
<CardDescription>
Associate your project with a Git repository to enable commit linking and to allow CI
integration.
<br />
<DocsLink
className="text-muted-foreground text-sm"
href="/management/projects#github-repository"
>
Learn more about GitHub integration
</DocsLink>
</CardDescription>
</CardHeader>
<CardContent>
{githubIntegration ? (
<>
<Select
name="gitRepository"
placeholder="None"
className="w-96"
options={githubIntegration.repositories.map(repo => ({
name: repo.nameWithOwner,
value: repo.nameWithOwner,
}))}
value={values.gitRepository ?? undefined}
onChange={handleChange}
onBlur={handleBlur}
isInvalid={!!(touched.gitRepository && errors.gitRepository)}
/>
{touched.gitRepository && (errors.gitRepository || mutation.error) && (
<div className="mt-2 text-red-500">
{errors.gitRepository ??
mutation.error?.graphQLErrors[0]?.message ??
mutation.error?.message}
</div>
)}
{mutation.data?.updateProjectGitRepository.error && (
<div className="mt-2 text-red-500">
{mutation.data.updateProjectGitRepository.error.message}
</div>
)}
</>
) : (
<Tag className="!p-4">
The organization is not connected to our GitHub Application.
<Link variant="primary" href={`/${router.organizationId}#settings`}>
Visit settings
</Link>
to configure it.
</Tag>
)}
</CardContent>
{githubIntegration ? (
<CardFooter>
<Button type="submit" className="px-10" disabled={isSubmitting}>
Save
</Button>
</CardFooter>
) : null}
</Card>
</form>
);
}
const Settings_UpdateProjectNameMutation = graphql(`
mutation Settings_UpdateProjectName($input: UpdateProjectNameInput!) {
const ProjectSettingsPage_UpdateProjectNameMutation = graphql(`
mutation ProjectSettingsPage_UpdateProjectName($input: UpdateProjectNameInput!) {
updateProjectName(input: $input) {
ok {
selector {
@ -157,8 +175,8 @@ const Settings_UpdateProjectNameMutation = graphql(`
}
`);
const SettingsPage_OrganizationFragment = graphql(`
fragment SettingsPage_OrganizationFragment on Organization {
const ProjectSettingsPage_OrganizationFragment = graphql(`
fragment ProjectSettingsPage_OrganizationFragment on Organization {
cleanId
me {
...CanAccessProject_MemberFragment
@ -167,8 +185,8 @@ const SettingsPage_OrganizationFragment = graphql(`
}
`);
const SettingsPage_ProjectFragment = graphql(`
fragment SettingsPage_ProjectFragment on Project {
const ProjectSettingsPage_ProjectFragment = graphql(`
fragment ProjectSettingsPage_ProjectFragment on Project {
name
gitRepository
type
@ -177,29 +195,63 @@ const SettingsPage_ProjectFragment = graphql(`
}
`);
const Page = (props: {
organization: FragmentType<typeof SettingsPage_OrganizationFragment>;
project: FragmentType<typeof SettingsPage_ProjectFragment>;
}) => {
const organization = useFragment(SettingsPage_OrganizationFragment, props.organization);
const project = useFragment(SettingsPage_ProjectFragment, props.project);
useProjectAccess({
scope: ProjectAccessScope.Settings,
member: organization.me,
redirect: true,
});
const ProjectSettingsPageQuery = graphql(`
query ProjectSettingsPageQuery($organizationId: ID!, $projectId: ID!) {
organization(selector: { organization: $organizationId }) {
organization {
...ProjectSettingsPage_OrganizationFragment
...ProjectLayout_CurrentOrganizationFragment
}
}
project(selector: { organization: $organizationId, project: $projectId }) {
...ProjectLayout_CurrentProjectFragment
...ProjectSettingsPage_ProjectFragment
}
organizations {
...ProjectLayout_OrganizationConnectionFragment
}
me {
...ProjectLayout_MeFragment
}
}
`);
function ProjectSettingsContent() {
const router = useRouteSelector();
const [isModalOpen, toggleModalOpen] = useToggle();
const [query] = useQuery({
query: ProjectSettingsPageQuery,
variables: {
organizationId: router.organizationId,
projectId: router.projectId,
},
requestPolicy: 'cache-and-network',
});
const [mutation, mutate] = useMutation(Settings_UpdateProjectNameMutation);
useNotFoundRedirectOnError(!!query.error);
const me = query.data?.me;
const currentOrganization = query.data?.organization?.organization;
const currentProject = query.data?.project;
const organizationConnection = query.data?.organizations;
const organization = useFragment(ProjectSettingsPage_OrganizationFragment, currentOrganization);
const project = useFragment(ProjectSettingsPage_ProjectFragment, currentProject);
useProjectAccess({
scope: ProjectAccessScope.Settings,
member: organization?.me ?? null,
redirect: true,
});
const [mutation, mutate] = useMutation(ProjectSettingsPage_UpdateProjectNameMutation);
const { handleSubmit, values, handleChange, handleBlur, isSubmitting, errors, touched } =
useFormik({
enableReinitialize: true,
initialValues: {
name: project?.name,
name: project?.name ?? '',
},
validationSchema: Yup.object().shape({
validationSchema: Yup.object({
name: Yup.string().required('Project name is required'),
}),
onSubmit: values =>
@ -217,127 +269,120 @@ const Page = (props: {
}),
});
return (
<>
<ModelMigrationSettings project={project} organizationId={organization.cleanId} />
<Card>
<Heading className="mb-2">Project Name</Heading>
<DocsNote warn>
Changing the name of your project will also change the slug of your project URL, and will
invalidate any existing links to your project.
<br />
<DocsLink href="/management/projects#rename-a-project">
You can read more about it in the documentation
</DocsLink>
</DocsNote>
<form onSubmit={handleSubmit} className="flex gap-x-2">
<Input
placeholder="Project name"
name="name"
value={values.name}
onChange={handleChange}
onBlur={handleBlur}
disabled={isSubmitting}
isInvalid={touched.name && !!errors.name}
className="w-96"
/>
<Button
type="submit"
variant="primary"
size="large"
className="px-10"
disabled={isSubmitting}
>
Save
</Button>
</form>
{touched.name && (errors.name || mutation.error) && (
<div className="mt-2 text-red-500">
{errors.name ?? mutation.error?.graphQLErrors[0]?.message ?? mutation.error?.message}
</div>
)}
{mutation.data?.updateProjectName.error && (
<div className="mt-2 text-red-500">{mutation.data.updateProjectName.error.message}</div>
)}
</Card>
<Card>
<Heading className="mb-2">Git Repository</Heading>
<DocsNote>
Associate your project with a Git repository to enable commit linking and to allow CI
integration.
<br />
<DocsLink href="/management/projects#github-repository">
Learn more about GitHub integration
</DocsLink>
</DocsNote>
<GitHubIntegration gitRepository={project.gitRepository ?? null} />
</Card>
{project.type === ProjectType.Federation ? (
<ExternalCompositionSettings project={project} organization={organization} />
) : null}
{canAccessProject(ProjectAccessScope.Delete, organization.me) && (
<Card>
<div className="flex items-center justify-between">
<div>
<Heading className="mb-2">Delete Project</Heading>
<DocsNote warn>
Deleting an project will delete all the targets, schemas and data associated with
it.
<br />
<DocsLink href="/management/projects#delete-a-project">
<strong>This action is not reversible!</strong> You can find more information
about this process in the documentation
</DocsLink>
</DocsNote>
</div>
<div className="flex items-center gap-x-2">
<Button
variant="primary"
size="large"
danger
onClick={toggleModalOpen}
className="px-5"
>
Delete Project
</Button>
</div>
</div>
</Card>
)}
<DeleteProjectModal isOpen={isModalOpen} toggleModalOpen={toggleModalOpen} />
</>
);
};
const ProjectSettingsPageQuery = graphql(`
query ProjectSettingsPageQuery($organizationId: ID!, $projectId: ID!) {
organization(selector: { organization: $organizationId }) {
organization {
...SettingsPage_OrganizationFragment
...ProjectLayout_OrganizationFragment
}
}
project(selector: { organization: $organizationId, project: $projectId }) {
...ProjectLayout_ProjectFragment
...SettingsPage_ProjectFragment
}
if (query.error) {
return null;
}
`);
function SettingsPage(): ReactElement {
return (
<ProjectLayout
currentOrganization={currentOrganization ?? null}
currentProject={currentProject ?? null}
organizations={organizationConnection ?? null}
me={me ?? null}
value="settings"
className="flex flex-col gap-y-10"
>
<div>
<div className="py-6">
<Title>Settings</Title>
<Subtitle>Manage your project settings</Subtitle>
</div>
<div className="flex flex-col gap-y-4">
{project && organization ? (
<>
<ModelMigrationSettings project={project} organizationId={organization.cleanId} />
<form onSubmit={handleSubmit}>
<Card>
<CardHeader>
<CardTitle>Project Name</CardTitle>
<CardDescription>
Changing the name of your project will also change the slug of your project
URL, and will invalidate any existing links to your project.
<br />
<DocsLink
className="text-muted-foreground text-sm"
href="/management/projects#rename-a-project"
>
You can read more about it in the documentation
</DocsLink>
</CardDescription>
</CardHeader>
<CardContent>
<Input
placeholder="Project name"
name="name"
value={values.name}
onChange={handleChange}
onBlur={handleBlur}
disabled={isSubmitting}
isInvalid={touched.name && !!errors.name}
className="w-96"
/>
{touched.name && (errors.name || mutation.error) && (
<div className="mt-2 text-red-500">
{errors.name ??
mutation.error?.graphQLErrors[0]?.message ??
mutation.error?.message}
</div>
)}
{mutation.data?.updateProjectName.error && (
<div className="mt-2 text-red-500">
{mutation.data.updateProjectName.error.message}
</div>
)}
</CardContent>
<CardFooter>
<Button type="submit" disabled={isSubmitting}>
Save
</Button>
</CardFooter>
</Card>
</form>
<GitHubIntegration gitRepository={project.gitRepository ?? null} />
{project.type === ProjectType.Federation ? (
<ExternalCompositionSettings project={project} organization={organization} />
) : null}
{canAccessProject(ProjectAccessScope.Delete, organization.me) && (
<Card>
<CardHeader>
<CardTitle>Delete Project</CardTitle>
<CardDescription>
Deleting an project will delete all the targets, schemas and data associated
with it.
<br />
<DocsLink
className="text-muted-foreground text-sm"
href="/management/projects#delete-a-project"
>
<strong>This action is not reversible!</strong> You can find more
information about this process in the documentation
</DocsLink>
</CardDescription>
</CardHeader>
<CardFooter>
<Button variant="destructive" onClick={toggleModalOpen}>
Delete Project
</Button>
</CardFooter>
</Card>
)}
<DeleteProjectModal isOpen={isModalOpen} toggleModalOpen={toggleModalOpen} />
</>
) : null}
</div>
</div>
</ProjectLayout>
);
}
function SettingsPage() {
return (
<>
<Title title="Project settings" />
<ProjectLayout
value="settings"
className="flex flex-col gap-y-10"
query={ProjectSettingsPageQuery}
>
{props => <Page organization={props.organization.organization} project={props.project} />}
</ProjectLayout>
<MetaTitle title="Project settings" />
<ProjectSettingsContent />
</>
);
}

View file

@ -1,200 +1,387 @@
import { ReactElement } from 'react';
import { ReactElement, useMemo, useRef } from 'react';
import NextLink from 'next/link';
import { onlyText } from 'react-children-utilities';
import { formatISO, subDays } from 'date-fns';
import * as echarts from 'echarts';
import ReactECharts from 'echarts-for-react';
import { Globe, History } from 'lucide-react';
import AutoSizer from 'react-virtualized-auto-sizer';
import { useQuery } from 'urql';
import { authenticated } from '@/components/authenticated-container';
import { OrganizationLayout } from '@/components/layouts';
import { OrganizationLayout } from '@/components/layouts/organization';
import {
Activities,
Button,
Card,
EmptyList,
Heading,
Skeleton,
TimeAgo,
Title,
} from '@/components/v2';
import { getActivity } from '@/components/v2/activities';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/v2/dropdown';
import { LinkIcon, MoreIcon, SettingsIcon } from '@/components/v2/icon';
createEmptySeries,
fullSeries,
resolutionToMilliseconds,
} from '@/components/target/operations/utils';
import { Subtitle, Title } from '@/components/ui/page';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { Activities, Card, EmptyList, MetaTitle } from '@/components/v2';
import { FragmentType, graphql, useFragment } from '@/gql';
import { ProjectActivitiesDocument } from '@/graphql';
import { canAccessProject, ProjectAccessScope } from '@/lib/access/project';
import { ProjectType } from '@/gql/graphql';
import { writeLastVisitedOrganization } from '@/lib/cookies';
import { fixDuplicatedFragments } from '@/lib/graphql';
import { useClipboard } from '@/lib/hooks/use-clipboard';
import { useFormattedNumber } from '@/lib/hooks';
import { useNotFoundRedirectOnError } from '@/lib/hooks/use-not-found-redirect-on-error';
import { useRouteSelector } from '@/lib/hooks/use-route-selector';
import { withSessionProtection } from '@/lib/supertokens/guard';
import { pluralize } from '@/lib/utils';
const projectActivitiesDocument = fixDuplicatedFragments(ProjectActivitiesDocument);
function floorDate(date: Date): Date {
const time = 1000 * 60;
return new Date(Math.floor(date.getTime() / time) * time);
}
const ProjectCard_ProjectFragment = graphql(`
fragment ProjectCard_ProjectFragment on Project {
cleanId
id
name
type
}
`);
const ProjectCard_OrganizationFragment = graphql(`
fragment ProjectCard_OrganizationFragment on Organization {
me {
...CanAccessProject_MemberFragment
}
}
`);
const projectTypeFullNames = {
[ProjectType.Federation]: 'Apollo Federation',
[ProjectType.Stitching]: 'Schema Stitching',
[ProjectType.Single]: 'Monolithic Schema',
};
const ProjectCard = (props: {
project: FragmentType<typeof ProjectCard_ProjectFragment>;
organization: FragmentType<typeof ProjectCard_OrganizationFragment>;
project: FragmentType<typeof ProjectCard_ProjectFragment> | null;
highestNumberOfRequests: number;
period: {
from: string;
to: string;
};
requestsOverTime: { date: string; value: number }[] | null;
schemaVersionsCount: number | null;
days: number;
}): ReactElement | null => {
const project = useFragment(ProjectCard_ProjectFragment, props.project);
const organization = useFragment(ProjectCard_OrganizationFragment, props.organization);
const copyToClipboard = useClipboard();
const router = useRouteSelector();
const [projectActivitiesQuery] = useQuery({
query: projectActivitiesDocument,
variables: {
selector: {
organization: router.organizationId,
project: project.cleanId,
limit: 3,
},
},
requestPolicy: 'cache-and-network',
});
const href = `/${router.organizationId}/${project.cleanId}`;
const lastActivity = projectActivitiesQuery.data?.projectActivities.nodes[0];
const href = project ? `/${router.organizationId}/${project.cleanId}` : '';
const { period, highestNumberOfRequests } = props;
const interval = resolutionToMilliseconds(props.days, period);
const requests = useMemo(() => {
if (props.requestsOverTime?.length) {
return fullSeries(
props.requestsOverTime.map<[string, number]>(node => [node.date, node.value]),
interval,
props.period,
);
}
return createEmptySeries({ interval, period });
}, [interval]);
const totalNumberOfRequests = useMemo(
() => requests.reduce((acc, [_, value]) => acc + value, 0),
[requests],
);
const totalNumberOfVersions = props.schemaVersionsCount ?? 0;
const requestsInDateRange = useFormattedNumber(totalNumberOfRequests);
const schemaVersionsInDateRange = useFormattedNumber(totalNumberOfVersions);
return (
<Card
as={NextLink}
key={project.id}
href={href}
className="h-full self-start hover:bg-gray-800/40"
className="h-full pt-4 px-0 self-start hover:bg-gray-800/40 hover:shadow-md hover:shadow-gray-800/50"
>
<div className="flex items-start gap-x-2">
<div className="grow">
<h4 className="line-clamp-2 text-lg font-bold">{project.name}</h4>
<TooltipProvider>
<div className="flex items-start gap-x-2">
<div className="grow">
<div>
<AutoSizer disableHeight>
{size => (
<ReactECharts
style={{ width: size.width, height: 90 }}
option={{
animation: !!project,
color: ['#f4b740'],
grid: {
left: 0,
top: 10,
right: 0,
bottom: 10,
},
tooltip: {
trigger: 'axis',
axisPointer: {
label: {
formatter({ value }: { value: number }) {
return new Date(value).toDateString();
},
},
},
},
xAxis: [
{
show: false,
type: 'time',
boundaryGap: false,
},
],
yAxis: [
{
show: false,
type: 'value',
min: 0,
max: highestNumberOfRequests,
},
],
series: [
{
name: 'Requests',
type: 'line',
smooth: false,
lineStyle: {
width: 2,
},
showSymbol: false,
areaStyle: {
opacity: 0.8,
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: 'rgba(244, 184, 64, 0.20)',
},
{
offset: 1,
color: 'rgba(244, 184, 64, 0)',
},
]),
},
emphasis: {
focus: 'series',
},
data: requests,
},
],
}}
/>
)}
</AutoSizer>
</div>
<div className="flex flex-row gap-y-3 px-4 pt-4 justify-between items-center">
{project ? (
<div>
<h4 className="line-clamp-2 text-lg font-bold">{project.name}</h4>
<p className="text-gray-300 text-xs">{projectTypeFullNames[project.type]}</p>
</div>
) : (
<div>
<div className="w-48 h-4 mb-4 py-2 bg-gray-800 rounded-full animate-pulse" />
<div className="w-24 h-2 bg-gray-800 rounded-full animate-pulse" />
</div>
)}
<div className="flex flex-col gap-y-2 py-1">
{project ? (
<>
<Tooltip>
<TooltipTrigger>
<div className="flex flex-row gap-x-2 items-center">
<Globe className="w-4 h-4 text-gray-500" />
<div className="text-xs">
{requestsInDateRange}{' '}
{pluralize(totalNumberOfRequests, 'request', 'requests')}
</div>
</div>
</TooltipTrigger>
<TooltipContent>
Number of GraphQL requests in the last {props.days} days.
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger>
<div className="flex flex-row gap-x-2 items-center">
<History className="w-4 h-4 text-gray-500" />
<div className="text-xs">
{schemaVersionsInDateRange}{' '}
{pluralize(totalNumberOfVersions, 'commit', 'commits')}
</div>
</div>
</TooltipTrigger>
<TooltipContent>
Number of schemas pushed to this project in the last {props.days} days.
</TooltipContent>
</Tooltip>
</>
) : (
<>
<div className="w-16 h-2 my-1 bg-gray-800 rounded-full animate-pulse" />
<div className="w-16 h-2 my-1 bg-gray-800 rounded-full animate-pulse" />
</>
)}
</div>
</div>
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button>
<MoreIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent sideOffset={5} align="start">
<DropdownMenuItem
onClick={async e => {
e.stopPropagation();
await copyToClipboard(`${location.origin}${href}`);
}}
>
<LinkIcon />
Share Link
</DropdownMenuItem>
{canAccessProject(ProjectAccessScope.Settings, organization.me) && (
<NextLink href={`/${router.organizationId}/${project.cleanId}/view/settings`}>
<DropdownMenuItem>
<SettingsIcon />
Settings
</DropdownMenuItem>
</NextLink>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
{lastActivity && (
<div className="mt-5 text-xs font-medium text-gray-500">
<span className="line-clamp-3">
{/* fixes Warning: validateDOMNesting(...): <a> cannot appear as a descendant of <a> */}
{onlyText(getActivity(lastActivity).content)}{' '}
<TimeAgo date={lastActivity.createdAt} className="text-gray-300" />
</span>
</div>
)}
</TooltipProvider>
</Card>
);
};
const OrganizationProjectsPageQuery = graphql(`
query OrganizationProjectsPageQuery($selector: OrganizationSelectorInput!) {
organization(selector: $selector) {
query OrganizationProjectsPageQuery(
$organizationId: ID!
$chartResolution: Int!
$period: DateRangeInput!
) {
organization(selector: { organization: $organizationId }) {
organization {
...OrganizationLayout_OrganizationFragment
...ProjectCard_OrganizationFragment
...OrganizationLayout_CurrentOrganizationFragment
}
}
projects(selector: $selector) {
projects(selector: { organization: $organizationId }) {
total
nodes {
id
name
...ProjectCard_ProjectFragment
requestsOverTime(resolution: $chartResolution, period: $period) {
date
value
}
schemaVersionsCount(period: $period)
}
}
organizations {
...OrganizationLayout_OrganizationConnectionFragment
}
me {
...OrganizationLayout_MeFragment
}
}
`);
function ProjectsPage(): ReactElement {
function OrganizationPageContent() {
const router = useRouteSelector();
const days = 14;
const period = useRef<{
from: string;
to: string;
}>();
if (!period.current) {
const now = floorDate(new Date());
const from = formatISO(subDays(now, days));
const to = formatISO(now);
period.current = { from, to };
}
const [query] = useQuery({
query: OrganizationProjectsPageQuery,
variables: {
organizationId: router.organizationId,
chartResolution: days, // 14 days = 14 data points
period: period.current,
},
});
useNotFoundRedirectOnError(!!query.error);
const me = query.data?.me;
const currentOrganization = query.data?.organization?.organization;
const organizationConnection = query.data?.organizations;
const projects = query.data?.projects;
const highestNumberOfRequests = useMemo(() => {
let highest = 10;
if (projects?.nodes.length) {
for (const project of projects.nodes) {
for (const dataPoint of project.requestsOverTime) {
if (dataPoint.value > highest) {
highest = dataPoint.value;
}
}
}
}
return highest;
}, [projects]);
if (query.error) {
return null;
}
return (
<OrganizationLayout
value="overview"
className="flex justify-between gap-12"
currentOrganization={currentOrganization ?? null}
organizations={organizationConnection ?? null}
me={me ?? null}
>
<>
<div className="grow">
<div className="py-6">
<Title>Projects</Title>
<Subtitle>A list of available project in your organization.</Subtitle>
</div>
{currentOrganization && projects ? (
projects.total === 0 ? (
<EmptyList
title="Hive is waiting for your first project"
description='You can create a project by clicking the "Create Project" button'
docsUrl="/management/projects#create-a-new-project"
/>
) : (
<div className="grid grid-cols-2 gap-5 items-stretch">
{projects.nodes
.sort((a, b) => {
const diff = b.schemaVersionsCount - a.schemaVersionsCount;
if (diff !== 0) {
return diff;
}
return a.name.localeCompare(b.name);
})
.map(project => (
<ProjectCard
key={project.id}
days={days}
highestNumberOfRequests={highestNumberOfRequests}
period={period.current!}
project={project}
requestsOverTime={project.requestsOverTime}
schemaVersionsCount={project.schemaVersionsCount}
/>
))}
</div>
)
) : (
<div className="grid grid-cols-2 gap-5 items-stretch">
{Array.from({ length: 4 }).map((_, index) => (
<ProjectCard
key={index}
days={days}
highestNumberOfRequests={highestNumberOfRequests}
period={period.current!}
project={null}
requestsOverTime={null}
schemaVersionsCount={null}
/>
))}
</div>
)}
</div>
<Activities />
</>
</OrganizationLayout>
);
}
function OrganizationPage(): ReactElement {
return (
<>
<Title title="Projects" />
<OrganizationLayout
value="overview"
className="flex justify-between gap-5"
query={OrganizationProjectsPageQuery}
>
{({ projects, organization }) =>
projects &&
organization?.organization && (
<>
<div className="grow">
<Heading className="mb-4">Active Projects</Heading>
{projects.total === 0 ? (
<EmptyList
title="Hive is waiting for your first project"
description='You can create a project by clicking the "Create Project" button'
docsUrl="/management/projects#create-a-new-project"
/>
) : (
<div className="grid grid-cols-2 gap-5 items-stretch">
{/** TODO: use defer here :) */}
{projects === null
? [1, 2].map(key => (
<Card key={key}>
<div className="flex gap-x-2">
<Skeleton visible className="h-12 w-12" />
<div>
<Skeleton visible className="mb-2 h-3 w-16" />
<Skeleton visible className="h-3 w-8" />
</div>
</div>
<Skeleton visible className="mt-5 mb-3 h-7 w-1/2" />
<Skeleton visible className="h-7" />
</Card>
))
: projects.nodes.map(project => (
<ProjectCard
key={project.id}
project={project}
organization={organization.organization}
/>
))}
</div>
)}
</div>
<Activities />
</>
)
}
</OrganizationLayout>
<MetaTitle title="Organization" />
<OrganizationPageContent />
</>
);
}
@ -204,4 +391,4 @@ export const getServerSideProps = withSessionProtection(async ({ req, res, resol
return { props: {} };
});
export default authenticated(ProjectsPage);
export default authenticated(OrganizationPage);

View file

@ -1,12 +1,16 @@
import { ReactElement, useCallback, useEffect, useRef, useState } from 'react';
import NextLink from 'next/link';
import { useMutation, useQuery } from 'urql';
import { authenticated } from '@/components/authenticated-container';
import { Section } from '@/components/common';
import { QueryError } from '@/components/common/DataWrapper';
import { OrganizationLayout } from '@/components/layouts';
import { OrganizationLayout } from '@/components/layouts/organization';
import { BillingPaymentMethod } from '@/components/organization/billing/BillingPaymentMethod';
import { BillingPlanPicker } from '@/components/organization/billing/BillingPlanPicker';
import { PlanSummary } from '@/components/organization/billing/PlanSummary';
import { Button, Card, Heading, Input, Slider, Stat, Title } from '@/components/v2';
import { Button } from '@/components/ui/button';
import { Subtitle, Title } from '@/components/ui/page';
import { Card, Heading, Input, MetaTitle, Slider, Stat } from '@/components/v2';
import { FragmentType, graphql, useFragment } from '@/gql';
import { BillingPlanType } from '@/gql/graphql';
import {
@ -16,6 +20,10 @@ import {
UpgradeToProDocument,
} from '@/graphql';
import { OrganizationAccessScope, useOrganizationAccess } from '@/lib/access/organization';
import { getIsStripeEnabled } from '@/lib/billing/stripe-public-key';
import { useRouteSelector } from '@/lib/hooks';
import { useNotFoundRedirectOnError } from '@/lib/hooks/use-not-found-redirect-on-error';
import { withSessionProtection } from '@/lib/supertokens/guard';
import { openChatSupport } from '@/utils';
import { CardElement, useElements, useStripe } from '@stripe/react-stripe-js';
@ -176,7 +184,7 @@ function Inner(props: {
if (organization.rateLimit.operations !== operationsRateLimit * 1_000_000) {
return (
<>
<Button variant="primary" type="button" onClick={updateLimits}>
<Button type="button" onClick={updateLimits}>
Update Limits
</Button>
<Section.Subtitle className="mt-4">
@ -191,7 +199,7 @@ function Inner(props: {
if (plan === 'ENTERPRISE') {
return (
<Button variant="primary" type="button" onClick={openChatSupport}>
<Button type="button" onClick={openChatSupport}>
Contact Us
</Button>
);
@ -199,7 +207,7 @@ function Inner(props: {
if (plan === 'PRO') {
return (
<Button variant="primary" type="button" onClick={upgrade} disabled={!paymentDetailsValid}>
<Button type="button" onClick={upgrade} disabled={!paymentDetailsValid}>
Upgrade to Pro
</Button>
);
@ -207,7 +215,7 @@ function Inner(props: {
if (plan === 'HOBBY') {
return (
<Button variant="primary" type="button" onClick={downgrade}>
<Button type="button" onClick={downgrade}>
Downgrade to Hobby
</Button>
);
@ -322,30 +330,118 @@ const ManageSubscriptionPageQuery = graphql(`
query ManageSubscriptionPageQuery($selector: OrganizationSelectorInput!) {
organization(selector: $selector) {
organization {
...OrganizationLayout_OrganizationFragment
cleanId
...OrganizationLayout_CurrentOrganizationFragment
...ManageSubscriptionInner_OrganizationFragment
}
}
billingPlans {
...ManageSubscriptionInner_BillingPlansFragment
}
organizations {
...OrganizationLayout_OrganizationConnectionFragment
}
me {
...OrganizationLayout_MeFragment
}
}
`);
export default function ManageSubscriptionPage(): ReactElement {
function ManageSubscriptionPageContent() {
const router = useRouteSelector();
const [query] = useQuery({
query: ManageSubscriptionPageQuery,
variables: {
selector: {
organization: router.organizationId,
},
},
});
useNotFoundRedirectOnError(!!query.error);
const me = query.data?.me;
const currentOrganization = query.data?.organization?.organization;
const organizationConnection = query.data?.organizations;
const billingPlans = query.data?.billingPlans;
const organization = useFragment(
ManageSubscriptionInner_OrganizationFragment,
currentOrganization,
);
const canAccess = useOrganizationAccess({
scope: OrganizationAccessScope.Settings,
member: organization?.me ?? null,
redirect: true,
});
if (query.error) {
return null;
}
if (!currentOrganization || !me || !organizationConnection || !organization || !billingPlans) {
return null;
}
if (!canAccess) {
return null;
}
return (
<OrganizationLayout
value="subscription"
className="flex flex-col gap-y-10"
currentOrganization={currentOrganization}
organizations={organizationConnection}
me={me}
>
<div className="grow">
<div className="py-6 flex flex-row justify-between items-center">
<div>
<Title>Manage subscription</Title>
<Subtitle>Manage your current plan and invoices.</Subtitle>
</div>
<div>
<Button asChild>
<NextLink href={`/${currentOrganization.cleanId}/view/subscription`}>
Subscription usage
</NextLink>
</Button>
</div>
</div>
<div>
<Inner organization={currentOrganization} billingPlans={billingPlans} />
</div>
</div>
</OrganizationLayout>
);
}
function ManageSubscriptionPage(): ReactElement {
return (
<>
<Title title="Manage Subscription" />
<OrganizationLayout query={ManageSubscriptionPageQuery}>
{props =>
props.organization ? (
<Inner
organization={props.organization.organization}
billingPlans={props.billingPlans}
/>
) : null
}
</OrganizationLayout>
<MetaTitle title="Manage Subscription" />
<ManageSubscriptionPageContent />
</>
);
}
export const getServerSideProps = withSessionProtection(async context => {
/**
* If Stripe is not enabled we redirect the user to the organization.
*/
const isStripeEnabled = getIsStripeEnabled();
if (!isStripeEnabled) {
const parts = String(context.resolvedUrl).split('/');
parts.pop();
return {
redirect: {
destination: parts.join('/'),
permanent: false,
},
};
}
return { props: {} };
});
export default authenticated(ManageSubscriptionPage);

View file

@ -3,18 +3,11 @@ import { useFormik } from 'formik';
import { useMutation, useQuery } from 'urql';
import * as Yup from 'yup';
import { authenticated } from '@/components/authenticated-container';
import { OrganizationLayout } from '@/components/layouts';
import {
Avatar,
Button,
Card,
Checkbox,
DocsLink,
DocsNote,
Heading,
Input,
Title,
} from '@/components/v2';
import { OrganizationLayout } from '@/components/layouts/organization';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Subtitle, Title } from '@/components/ui/page';
import { Avatar, Card, DocsLink, Heading, Input, MetaTitle } from '@/components/v2';
import {
DropdownMenu,
DropdownMenuContent,
@ -27,6 +20,7 @@ import { FragmentType, graphql, useFragment } from '@/gql';
import { MeDocument, OrganizationFieldsFragment } from '@/graphql';
import { OrganizationAccessScope, useOrganizationAccess } from '@/lib/access/organization';
import { useClipboard } from '@/lib/hooks/use-clipboard';
import { useNotFoundRedirectOnError } from '@/lib/hooks/use-not-found-redirect-on-error';
import { useNotifications } from '@/lib/hooks/use-notifications';
import { useRouteSelector } from '@/lib/hooks/use-route-selector';
import { useToggle } from '@/lib/hooks/use-toggle';
@ -124,7 +118,7 @@ const MemberInvitationForm = ({
return (
<div>
<form onSubmit={handleSubmit} className="flex flex-row gap-2">
<form onSubmit={handleSubmit} className="flex flex-row gap-2 items-center">
<Input
style={{
minWidth: '200px',
@ -139,13 +133,7 @@ const MemberInvitationForm = ({
isInvalid={touched.email && !!errors.email}
onClear={resetForm}
/>
<Button
type="submit"
size="large"
block
variant="primary"
disabled={isSubmitting || !isValid || !dirty}
>
<Button type="submit" disabled={isSubmitting || !isValid || !dirty}>
Send an invite
</Button>
</form>
@ -201,7 +189,7 @@ const Invitation = (props: {
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button>
<Button variant="ghost">
<MoreIcon />
</Button>
</DropdownMenuTrigger>
@ -309,13 +297,18 @@ function Page(props: { organization: FragmentType<typeof Page_OrganizationFragme
: null;
return (
<>
<DocsNote>
You may invite other members to collaborate with you on this organization.{' '}
<DocsLink href="/management/organizations#members">
Learn more about membership and invitations
</DocsLink>
</DocsNote>
<div className="flex flex-col gap-y-4">
<div className="py-6">
<Title>Members</Title>
<Subtitle>
You may invite other members to collaborate with you on this organization.
</Subtitle>
<p>
<DocsLink href="/management/organizations#members" className="text-muted-foreground">
Learn more about membership and invitations
</DocsLink>
</p>
</div>
{selectedMember && (
<ChangePermissionsModal
isOpen={isPermissionsModalOpen}
@ -332,14 +325,13 @@ function Page(props: { organization: FragmentType<typeof Page_OrganizationFragme
<div className="flex items-center justify-between">
<MemberInvitationForm organizationCleanId={organization.cleanId} />
<Button
size="large"
danger
className="min-w-[150px] justify-between"
variant="destructive"
className="flex flex-row items-center justify-between"
onClick={toggleDeleteMembersModalOpen}
disabled={checked.length === 0}
>
Delete {checked.length || ''}
<TrashIcon />
<TrashIcon className="ml-2 w-4 h-4" />
</Button>
</div>
{members?.map(node => {
@ -368,7 +360,7 @@ function Page(props: { organization: FragmentType<typeof Page_OrganizationFragme
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className={isDisabled ? 'invisible' : ''}>
<Button variant="ghost" className={isDisabled ? 'invisible' : ''}>
<MoreIcon />
</Button>
</DropdownMenuTrigger>
@ -396,7 +388,7 @@ function Page(props: { organization: FragmentType<typeof Page_OrganizationFragme
);
})}
<OrganizationInvitations organization={organization} />
</>
</div>
);
}
@ -404,26 +396,58 @@ const OrganizationMembersPageQuery = graphql(`
query OrganizationMembersPageQuery($selector: OrganizationSelectorInput!) {
organization(selector: $selector) {
organization {
...OrganizationLayout_OrganizationFragment
...OrganizationLayout_CurrentOrganizationFragment
...Page_OrganizationFragment
}
}
organizations {
...OrganizationLayout_OrganizationConnectionFragment
}
me {
...OrganizationLayout_MeFragment
}
}
`);
function SettingsPageContent() {
const router = useRouteSelector();
const [query] = useQuery({
query: OrganizationMembersPageQuery,
variables: {
selector: {
organization: router.organizationId,
},
},
});
useNotFoundRedirectOnError(!!query.error);
if (query.error) {
return null;
}
const me = query.data?.me;
const currentOrganization = query.data?.organization?.organization;
const organizationConnection = query.data?.organizations;
return (
<OrganizationLayout
value="members"
className="flex flex-col gap-y-10"
currentOrganization={currentOrganization ?? null}
organizations={organizationConnection ?? null}
me={me ?? null}
>
{currentOrganization ? <Page organization={currentOrganization} /> : null}
</OrganizationLayout>
);
}
function OrganizationMembersPage(): ReactElement {
return (
<>
<Title title="Members" />
<OrganizationLayout
value="members"
className="flex w-4/5 flex-col gap-4"
query={OrganizationMembersPageQuery}
>
{({ organization }) =>
organization ? <Page organization={organization.organization} /> : null
}
</OrganizationLayout>
<MetaTitle title="Members" />
<SettingsPageContent />
</>
);
}

View file

@ -1,11 +1,16 @@
import { ReactElement } from 'react';
import { useMutation } from 'urql';
import { useMutation, useQuery } from 'urql';
import { authenticated } from '@/components/authenticated-container';
import { OrganizationLayout } from '@/components/layouts';
import { OrganizationLayout } from '@/components/layouts/organization';
import { PolicySettings } from '@/components/policy/policy-settings';
import { Card, Checkbox, DocsLink, DocsNote, Heading, Title } from '@/components/v2';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { Subtitle, Title } from '@/components/ui/page';
import { DocsLink, DocsNote, MetaTitle } from '@/components/v2';
import { graphql } from '@/gql';
import { RegistryModel } from '@/graphql';
import { useRouteSelector } from '@/lib/hooks';
import { useNotFoundRedirectOnError } from '@/lib/hooks/use-not-found-redirect-on-error';
import { withSessionProtection } from '@/lib/supertokens/guard';
const OrganizationPolicyPageQuery = graphql(`
@ -13,7 +18,7 @@ const OrganizationPolicyPageQuery = graphql(`
organization(selector: $selector) {
organization {
id
...OrganizationLayout_OrganizationFragment
...OrganizationLayout_CurrentOrganizationFragment
projects {
nodes {
id
@ -28,6 +33,12 @@ const OrganizationPolicyPageQuery = graphql(`
}
}
}
organizations {
...OrganizationLayout_OrganizationConnectionFragment
}
me {
...OrganizationLayout_MeFragment
}
}
`);
@ -48,7 +59,7 @@ const UpdateSchemaPolicyForOrganization = graphql(`
ok {
organization {
id
...OrganizationLayout_OrganizationFragment
...OrganizationLayout_CurrentOrganizationFragment
schemaPolicy {
id
updatedAt
@ -61,54 +72,93 @@ const UpdateSchemaPolicyForOrganization = graphql(`
}
`);
function OrganizationPolicyPage(): ReactElement {
function PolicyPageContent() {
const router = useRouteSelector();
const [query] = useQuery({
query: OrganizationPolicyPageQuery,
variables: {
selector: {
organization: router.organizationId,
},
},
});
const [mutation, mutate] = useMutation(UpdateSchemaPolicyForOrganization);
useNotFoundRedirectOnError(!!query.error);
if (query.error) {
return null;
}
const me = query.data?.me;
const currentOrganization = query.data?.organization?.organization;
const organizationConnection = query.data?.organizations;
const legacyProjects = currentOrganization?.projects.nodes.filter(
p => p.registryModel === RegistryModel.Legacy,
);
return (
<>
<Title title="Organization Schema Policy" />
<OrganizationLayout
value="policy"
className="flex flex-col gap-y-10"
query={OrganizationPolicyPageQuery}
>
{(props, selector) => {
if (!props.organization) {
return null;
}
const legacyProjects = props.organization.organization.projects.nodes.filter(
p => p.registryModel === RegistryModel.Legacy,
);
return (
<Card>
<Heading className="mb-2">Organization Schema Policy</Heading>
<DocsNote>
<strong>Schema Policies</strong> enable developers to define additional semantic
checks on the GraphQL schema. At the organizational level, policies can be defined
to affect all projects and targets. At the project level, policies can be overridden
or extended. <DocsLink href="/features/schema-policy">Learn more</DocsLink>
</DocsNote>
{legacyProjects.length > 0 ? (
<div className="mt-4">
<DocsNote warn>
Note: some of your projects (
{legacyProjects.map(p => (
<code key={p.cleanId}>{p.cleanId}</code>
<OrganizationLayout
value="policy"
className="flex flex-col gap-y-10"
currentOrganization={currentOrganization ?? null}
organizations={organizationConnection ?? null}
me={me ?? null}
>
<div>
<div className="py-6">
<Title>Organization Schema Policy</Title>
<Subtitle>
Schema Policies enable developers to define additional semantic checks on the GraphQL
schema.
</Subtitle>
</div>
{currentOrganization ? (
<Card>
<CardHeader>
<CardTitle>Rules</CardTitle>
<CardDescription>
At the organizational level, policies can be defined to affect all projects and
targets.
<br />
At the project level, policies can be overridden or extended.
<br />
<DocsLink className="text-muted-foreground" href="/features/schema-policy">
Learn more
</DocsLink>
</CardDescription>
</CardHeader>
{legacyProjects && legacyProjects.length > 0 ? (
<div className="p-6">
<DocsNote warn>
<p>Some of your projects are using the legacy model of the schema registry.</p>
<p className="text-muted-foreground">
{legacyProjects.map((p, i, all) => (
<>
<code className="italic" key={p.cleanId}>
{p.cleanId}
</code>
{all.length === i - 1 ? ' ' : ', '}
</>
))}
) are using the legacy model of the schema registry.{' '}
<strong className="underline">
Policy feature is only available for projects that are using the new registry
model.
</strong>
<br />
<DocsLink href="https://the-guild.dev/blog/graphql-hive-improvements-in-schema-registry">
</p>
<p className="py-2 text-muted-foreground font-semibold underline">
Policy feature is only available for projects that are using the new registry
model.
</p>
<p>
<DocsLink
className="text-muted-foreground"
href="https://the-guild.dev/blog/graphql-hive-improvements-in-schema-registry"
>
Learn more
</DocsLink>
</DocsNote>
</div>
) : null}
</p>
</DocsNote>
</div>
) : null}
<CardContent>
<PolicySettings
saving={mutation.fetching}
error={
@ -117,15 +167,17 @@ function OrganizationPolicyPage(): ReactElement {
}
onSave={async (newPolicy, allowOverrides) => {
await mutate({
selector,
selector: {
organization: router.organizationId,
},
policy: newPolicy,
allowOverrides,
}).catch();
}}
currentState={props.organization.organization.schemaPolicy}
currentState={currentOrganization.schemaPolicy}
>
{form => (
<div className="flex pl-1 pt-2">
<div className="flex items-center pl-1 pt-2">
<Checkbox
id="allowOverrides"
checked={form.values.allowOverrides}
@ -141,10 +193,19 @@ function OrganizationPolicyPage(): ReactElement {
</div>
)}
</PolicySettings>
</Card>
);
}}
</OrganizationLayout>
</CardContent>
</Card>
) : null}
</div>
</OrganizationLayout>
);
}
function OrganizationPolicyPage(): ReactElement {
return (
<>
<MetaTitle title="Organization Schema Policy" />
<PolicyPageContent />
</>
);
}

View file

@ -1,11 +1,22 @@
import { ReactElement } from 'react';
import NextLink from 'next/link';
import { useFormik } from 'formik';
import { useMutation, useQuery } from 'urql';
import * as Yup from 'yup';
import { authenticated } from '@/components/authenticated-container';
import { OrganizationLayout } from '@/components/layouts';
import { OrganizationLayout } from '@/components/layouts/organization';
import { OIDCIntegrationSection } from '@/components/organization/settings/oidc-integration-section';
import { Button, Card, DocsLink, DocsNote, Heading, Input, Tag, Title } from '@/components/v2';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Subtitle, Title } from '@/components/ui/page';
import { DocsLink, Input, MetaTitle, Tag } from '@/components/v2';
import { GitHubIcon, SlackIcon } from '@/components/v2/icon';
import {
DeleteOrganizationModal,
@ -24,6 +35,7 @@ import {
useOrganizationAccess,
} from '@/lib/access/organization';
import { useRouteSelector, useToggle } from '@/lib/hooks';
import { useNotFoundRedirectOnError } from '@/lib/hooks/use-not-found-redirect-on-error';
import { withSessionProtection } from '@/lib/supertokens/guard';
function Integrations(): ReactElement | null {
@ -57,8 +69,7 @@ function Integrations(): ReactElement | null {
<div className="flex items-center gap-x-4">
{hasSlackIntegration ? (
<Button
size="large"
danger
variant="destructive"
disabled={deleteSlackMutation.fetching}
onClick={async () => {
await deleteSlack({
@ -72,9 +83,11 @@ function Integrations(): ReactElement | null {
Disconnect Slack
</Button>
) : (
<Button variant="secondary" size="large" as="a" href={`/api/slack/connect/${orgId}`}>
<SlackIcon className="mr-2" />
Connect Slack
<Button variant="secondary" asChild>
<NextLink href={`/api/slack/connect/${orgId}`}>
<SlackIcon className="mr-2" />
Connect Slack
</NextLink>
</Button>
)}
<Tag>Alerts and notifications</Tag>
@ -86,8 +99,7 @@ function Integrations(): ReactElement | null {
{hasGitHubIntegration ? (
<>
<Button
size="large"
danger
variant="destructive"
disabled={deleteGitHubMutation.fetching}
onClick={async () => {
await deleteGitHub({
@ -100,14 +112,16 @@ function Integrations(): ReactElement | null {
<GitHubIcon className="mr-2" />
Disconnect GitHub
</Button>
<Button size="large" variant="link" as="a" href={`/api/github/connect/${orgId}`}>
Adjust permissions
<Button variant="link" asChild>
<NextLink href={`/api/github/connect/${orgId}`}>Adjust permissions</NextLink>
</Button>
</>
) : (
<Button variant="secondary" size="large" as="a" href={`/api/github/connect/${orgId}`}>
<GitHubIcon className="mr-2" />
Connect GitHub
<Button variant="secondary" asChild>
<NextLink href={`/api/github/connect/${orgId}`}>
<GitHubIcon className="mr-2" />
Connect GitHub
</NextLink>
</Button>
)}
<Tag>Allow Hive to communicate with GitHub</Tag>
@ -194,125 +208,136 @@ const SettingsPageRenderer = (props: {
});
return (
<>
<Card>
<Heading className="mb-2">Organization Name</Heading>
<form onSubmit={handleSubmit} className="flex gap-x-2">
<Input
placeholder="Organization name"
name="name"
value={values.name}
onChange={handleChange}
onBlur={handleBlur}
disabled={isSubmitting}
isInvalid={touched.name && !!errors.name}
className="w-96"
/>
<Button
type="submit"
variant="primary"
size="large"
disabled={isSubmitting}
className="px-10"
>
Save
</Button>
</form>
{touched.name && (errors.name || mutation.error) && (
<div className="mt-2 text-red-500">{errors.name || mutation.error?.message}</div>
)}
{mutation.data?.updateOrganizationName?.error && (
<div className="mt-2 text-red-500">
{mutation.data?.updateOrganizationName.error.message}
</div>
)}
{mutation.error && (
<div>{mutation.error.graphQLErrors[0]?.message ?? mutation.error.message}</div>
)}
<DocsNote warn>
Changing the name of your organization will also change the slug of your organization URL,
and will invalidate any existing links to your organization.
<br />
<DocsLink href="/management/organizations#rename-an-organization">
You can read more about it in the documentation
</DocsLink>
</DocsNote>
</Card>
{canAccessOrganization(OrganizationAccessScope.Integrations, organization.me) && (
<div>
<div className="py-6">
<Title>Organization Settings</Title>
<Subtitle>Manage your organization settings and integrations.</Subtitle>
</div>
<div className="flex flex-col gap-y-4">
<Card>
<Heading className="mb-2">Integrations</Heading>
<DocsNote>
Authorize external services to make them available for your the projects under this
organization.
<br />
<DocsLink href="/management/organizations#integrations">
You can find here instructions and full documentation for the available integration
</DocsLink>
</DocsNote>
<div className="flex flex-col gap-y-4 text-gray-500">
<Integrations />
</div>
<CardHeader>
<CardTitle>Organization Name</CardTitle>
<CardDescription>
Changing the name of your organization will also change the slug of your organization
URL, and will invalidate any existing links to your organization.
<br />
<DocsLink
className="text-muted-foreground text-sm"
href="/management/organizations#rename-an-organization"
>
You can read more about it in the documentation
</DocsLink>
</CardDescription>
</CardHeader>
<CardContent className="grid gap-4">
<form onSubmit={handleSubmit} className="flex gap-x-2">
<Input
placeholder="Organization name"
name="name"
value={values.name}
onChange={handleChange}
onBlur={handleBlur}
disabled={isSubmitting}
isInvalid={touched.name && !!errors.name}
className="w-96"
/>
</form>
{touched.name && (errors.name || mutation.error) && (
<div className="mt-2 text-red-500">{errors.name || mutation.error?.message}</div>
)}
{mutation.data?.updateOrganizationName?.error && (
<div className="mt-2 text-red-500">
{mutation.data?.updateOrganizationName.error.message}
</div>
)}
{mutation.error && (
<div>{mutation.error.graphQLErrors[0]?.message ?? mutation.error.message}</div>
)}
</CardContent>
<CardFooter className="flex justify-between">
<Button disabled={isSubmitting} className="px-10">
Save
</Button>
</CardFooter>
</Card>
)}
{organization.me.isOwner && (
<Card>
<div className="flex items-center justify-between">
<div>
<Heading className="mb-2">Transfer Ownership</Heading>
<DocsNote>
{canAccessOrganization(OrganizationAccessScope.Integrations, organization.me) && (
<Card>
<CardHeader>
<CardTitle>Integrations</CardTitle>
<CardDescription>
Authorize external services to make them available for your the projects under this
organization.
<br />
<DocsLink
className="text-muted-foreground text-sm"
href="/management/organizations#integrations"
>
You can find here instructions and full documentation for the available
integration
</DocsLink>
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-y-4 text-gray-500">
<Integrations />
</div>
</CardContent>
</Card>
)}
{organization.me.isOwner && (
<Card>
<CardHeader>
<CardTitle>Transfer Ownership</CardTitle>
<CardDescription>
<strong>You are currently the owner of the organization.</strong> You can transfer
the organization to another member of the organization, or to an external user.
<br />
<DocsLink href="/management/organizations#transfer-ownership">
<DocsLink
className="text-muted-foreground text-sm"
href="/management/organizations#transfer-ownership"
>
Learn more about the process
</DocsLink>
</DocsNote>
</div>
<div>
<Button
variant="primary"
size="large"
danger
onClick={toggleTransferModalOpen}
className="px-5"
>
Transfer Ownership
</Button>
<TransferOrganizationOwnershipModal
isOpen={isTransferModalOpen}
toggleModalOpen={toggleTransferModalOpen}
organization={organization}
/>
</div>
</div>
</Card>
)}
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div>
<Button variant="destructive" onClick={toggleTransferModalOpen} className="px-5">
Transfer Ownership
</Button>
<TransferOrganizationOwnershipModal
isOpen={isTransferModalOpen}
toggleModalOpen={toggleTransferModalOpen}
organization={organization}
/>
</div>
</div>
</CardContent>
</Card>
)}
{canAccessOrganization(OrganizationAccessScope.Delete, organization.me) && (
<Card>
<div className="flex items-center justify-between">
<div>
<Heading className="mb-2">Delete Organization</Heading>
<DocsNote warn>
{canAccessOrganization(OrganizationAccessScope.Delete, organization.me) && (
<Card>
<CardHeader>
<CardTitle>Delete Organization</CardTitle>
<CardDescription>
Deleting an organization will delete all the projects, targets, schemas and data
associated with it.
<br />
<DocsLink href="/management/organizations#delete-an-organization">
<DocsLink
className="text-muted-foreground text-sm"
href="/management/organizations#delete-an-organization"
>
<strong>This action is not reversible!</strong> You can find more information
about this process in the documentation
</DocsLink>
</DocsNote>
</div>
<div className="flex items-center gap-x-2">
<Button
variant="primary"
size="large"
danger
onClick={toggleDeleteModalOpen}
className="px-5"
>
</CardDescription>
</CardHeader>
<CardContent>
<Button variant="destructive" onClick={toggleDeleteModalOpen} className="px-5">
Delete Organization
</Button>
<DeleteOrganizationModal
@ -320,11 +345,11 @@ const SettingsPageRenderer = (props: {
toggleModalOpen={toggleDeleteModalOpen}
organization={organization}
/>
</div>
</div>
</Card>
)}
</>
</CardContent>
</Card>
)}
</div>
</div>
);
};
@ -332,28 +357,58 @@ const OrganizationSettingsPageQuery = graphql(`
query OrganizationSettingsPageQuery($selector: OrganizationSelectorInput!) {
organization(selector: $selector) {
organization {
...OrganizationLayout_OrganizationFragment
...OrganizationLayout_CurrentOrganizationFragment
...SettingsPageRenderer_OrganizationFragment
}
}
organizations {
...OrganizationLayout_OrganizationConnectionFragment
}
me {
...OrganizationLayout_MeFragment
}
}
`);
function SettingsPageContent() {
const router = useRouteSelector();
const [query] = useQuery({
query: OrganizationSettingsPageQuery,
variables: {
selector: {
organization: router.organizationId,
},
},
});
useNotFoundRedirectOnError(!!query.error);
if (query.error) {
return null;
}
const me = query.data?.me;
const currentOrganization = query.data?.organization?.organization;
const organizationConnection = query.data?.organizations;
return (
<OrganizationLayout
value="settings"
className="flex flex-col gap-y-10"
currentOrganization={currentOrganization ?? null}
organizations={organizationConnection ?? null}
me={me ?? null}
>
{currentOrganization ? <SettingsPageRenderer organization={currentOrganization} /> : null}
</OrganizationLayout>
);
}
function OrganizationSettingsPage(): ReactElement {
return (
<>
<Title title="Organization settings" />
<OrganizationLayout
value="settings"
className="flex flex-col gap-y-10"
query={OrganizationSettingsPageQuery}
>
{props =>
props.organization ? (
<SettingsPageRenderer organization={props.organization.organization} />
) : null
}
</OrganizationLayout>
<MetaTitle title="Organization settings" />
<SettingsPageContent />
</>
);
}

View file

@ -0,0 +1,210 @@
import { ReactElement } from 'react';
import NextLink from 'next/link';
import { endOfMonth, startOfMonth } from 'date-fns';
import { useQuery } from 'urql';
import { authenticated } from '@/components/authenticated-container';
import { OrganizationLayout } from '@/components/layouts/organization';
import { BillingView } from '@/components/organization/billing/Billing';
import { CurrencyFormatter } from '@/components/organization/billing/helpers';
import { InvoicesList } from '@/components/organization/billing/InvoicesList';
import { OrganizationUsageEstimationView } from '@/components/organization/Usage';
import { Button } from '@/components/ui/button';
import { Subtitle, Title } from '@/components/ui/page';
import { Card, Heading, MetaTitle, Stat } from '@/components/v2';
import { graphql, useFragment } from '@/gql';
import { OrganizationAccessScope, useOrganizationAccess } from '@/lib/access/organization';
import { getIsStripeEnabled } from '@/lib/billing/stripe-public-key';
import { useRouteSelector } from '@/lib/hooks';
import { useNotFoundRedirectOnError } from '@/lib/hooks/use-not-found-redirect-on-error';
import { withSessionProtection } from '@/lib/supertokens/guard';
const DateFormatter = Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
const SubscriptionPage_OrganizationFragment = graphql(`
fragment SubscriptionPage_OrganizationFragment on Organization {
me {
...CanAccessOrganization_MemberFragment
}
billingConfiguration {
hasPaymentIssues
invoices {
id
}
upcomingInvoice {
amount
date
}
}
...RateLimitWarn_OrganizationFragment
...OrganizationInvoicesList_OrganizationFragment
...BillingView_OrganizationFragment
...OrganizationUsageEstimationView_OrganizationFragment
}
`);
const SubscriptionPage_QueryFragment = graphql(`
fragment SubscriptionPage_QueryFragment on Query {
...BillingView_QueryFragment
}
`);
const SubscriptionPageQuery = graphql(`
query SubscriptionPageQuery($selector: OrganizationSelectorInput!) {
organization(selector: $selector) {
organization {
cleanId
...OrganizationLayout_CurrentOrganizationFragment
...SubscriptionPage_OrganizationFragment
}
}
...SubscriptionPage_QueryFragment
organizations {
...OrganizationLayout_OrganizationConnectionFragment
}
me {
...OrganizationLayout_MeFragment
}
}
`);
function SubscriptionPageContent() {
const router = useRouteSelector();
const [query] = useQuery({
query: SubscriptionPageQuery,
variables: {
selector: {
organization: router.organizationId,
},
},
});
useNotFoundRedirectOnError(!!query.error);
const me = query.data?.me;
const currentOrganization = query.data?.organization?.organization;
const organizationConnection = query.data?.organizations;
const organization = useFragment(SubscriptionPage_OrganizationFragment, currentOrganization);
const queryForBilling = useFragment(SubscriptionPage_QueryFragment, query.data);
const canAccess = useOrganizationAccess({
scope: OrganizationAccessScope.Settings,
member: organization?.me ?? null,
redirect: true,
});
if (query.error || query.fetching) {
return null;
}
if (!currentOrganization || !me || !organizationConnection || !organization || !queryForBilling) {
return null;
}
if (!canAccess) {
return null;
}
const today = new Date();
const start = startOfMonth(today);
const end = endOfMonth(today);
return (
<OrganizationLayout
value="subscription"
className="flex flex-col gap-y-10"
currentOrganization={currentOrganization}
organizations={organizationConnection}
me={me}
>
<div className="grow">
<div className="py-6 flex flex-row justify-between items-center">
<div>
<Title>Your subscription</Title>
<Subtitle>Explore your current plan and usage.</Subtitle>
</div>
<div>
<Button asChild>
<NextLink href={`/${currentOrganization.cleanId}/view/manage-subscription`}>
Manage subscription
</NextLink>
</Button>
</div>
</div>
<div>
<Card>
<Heading className="mb-2">Your current plan</Heading>
<div>
<BillingView organization={organization} query={queryForBilling}>
{organization.billingConfiguration?.upcomingInvoice && (
<Stat>
<Stat.Label>Next Invoice</Stat.Label>
<Stat.Number>
{CurrencyFormatter.format(
organization.billingConfiguration.upcomingInvoice.amount,
)}
</Stat.Number>
<Stat.HelpText>
{DateFormatter.format(
new Date(organization.billingConfiguration.upcomingInvoice.date),
)}
</Stat.HelpText>
</Stat>
)}
</BillingView>
</div>
</Card>
<Card className="mt-8">
<Heading>Current Usage</Heading>
<p className="text-sm text-gray-500">
{DateFormatter.format(start)} {DateFormatter.format(end)}
</p>
<div className="mt-4">
<OrganizationUsageEstimationView organization={organization} />
</div>
</Card>
{organization.billingConfiguration?.invoices?.length ? (
<Card className="mt-8">
<Heading>Invoices</Heading>
<div className="mt-4">
<InvoicesList organization={organization} />
</div>
</Card>
) : null}
</div>
</div>
</OrganizationLayout>
);
}
function SubscriptionPage(): ReactElement {
return (
<>
<MetaTitle title="Subscription & Usage" />
<SubscriptionPageContent />
</>
);
}
export const getServerSideProps = withSessionProtection(async context => {
/**
* If Stripe is not enabled we redirect the user to the organization.
*/
const isStripeEnabled = getIsStripeEnabled();
if (!isStripeEnabled) {
const parts = String(context.resolvedUrl).split('/');
parts.pop();
return {
redirect: {
destination: parts.join('/'),
permanent: false,
},
};
}
return { props: {} };
});
export default authenticated(SubscriptionPage);

View file

@ -1,175 +0,0 @@
import { ReactElement } from 'react';
import dynamic from 'next/dynamic';
import { endOfMonth, startOfMonth } from 'date-fns';
import { authenticated } from '@/components/authenticated-container';
import { OrganizationLayout } from '@/components/layouts';
import { BillingView } from '@/components/organization/billing/Billing';
import { CurrencyFormatter } from '@/components/organization/billing/helpers';
import { InvoicesList } from '@/components/organization/billing/InvoicesList';
import { OrganizationUsageEstimationView } from '@/components/organization/Usage';
import { Card, Heading, Stat, Tabs, Title } from '@/components/v2';
import { FragmentType, graphql, useFragment } from '@/gql';
import { OrganizationAccessScope, useOrganizationAccess } from '@/lib/access/organization';
import { getIsStripeEnabled } from '@/lib/billing/stripe-public-key';
import { withSessionProtection } from '@/lib/supertokens/guard';
const DateFormatter = Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
const ManagePage = dynamic(() => import('./manage'));
const SubscriptionPage_OrganizationFragment = graphql(`
fragment SubscriptionPage_OrganizationFragment on Organization {
me {
...CanAccessOrganization_MemberFragment
}
billingConfiguration {
hasPaymentIssues
invoices {
id
}
upcomingInvoice {
amount
date
}
}
...RateLimitWarn_OrganizationFragment
...OrganizationInvoicesList_OrganizationFragment
...BillingView_OrganizationFragment
...OrganizationUsageEstimationView_OrganizationFragment
}
`);
const SubscriptionPage_QueryFragment = graphql(`
fragment SubscriptionPage_QueryFragment on Query {
...BillingView_QueryFragment
}
`);
function Page(props: {
organization: FragmentType<typeof SubscriptionPage_OrganizationFragment>;
query: FragmentType<typeof SubscriptionPage_QueryFragment>;
}): ReactElement | null {
const organization = useFragment(SubscriptionPage_OrganizationFragment, props.organization);
const query = useFragment(SubscriptionPage_QueryFragment, props.query);
const canAccess = useOrganizationAccess({
scope: OrganizationAccessScope.Settings,
member: organization?.me,
redirect: true,
});
if (!canAccess) {
return null;
}
const today = new Date();
const start = startOfMonth(today);
const end = endOfMonth(today);
return (
<Tabs defaultValue="overview">
<Tabs.List>
<Tabs.Trigger value="overview" hasBorder={false}>
Monthly Usage
</Tabs.Trigger>
<Tabs.Trigger value="manage" hasBorder={false}>
Manage
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="overview">
<Card>
<Heading className="mb-2">Your current plan</Heading>
<div>
<BillingView organization={organization} query={query}>
{organization.billingConfiguration?.upcomingInvoice && (
<Stat>
<Stat.Label>Next Invoice</Stat.Label>
<Stat.Number>
{CurrencyFormatter.format(
organization.billingConfiguration.upcomingInvoice.amount,
)}
</Stat.Number>
<Stat.HelpText>
{DateFormatter.format(
new Date(organization.billingConfiguration.upcomingInvoice.date),
)}
</Stat.HelpText>
</Stat>
)}
</BillingView>
</div>
</Card>
<Card className="mt-8">
<Heading>Current Usage</Heading>
<p className="text-sm text-gray-500">
{DateFormatter.format(start)} {DateFormatter.format(end)}
</p>
<div className="mt-4">
<OrganizationUsageEstimationView organization={organization} />
</div>
</Card>
{organization.billingConfiguration?.invoices?.length ? (
<Card className="mt-8">
<Heading>Invoices</Heading>
<div className="mt-4">
<InvoicesList organization={organization} />
</div>
</Card>
) : null}
</Tabs.Content>
<Tabs.Content value="manage">
<ManagePage />
</Tabs.Content>
</Tabs>
);
}
const SubscriptionPageQuery = graphql(`
query SubscriptionPageQuery($selector: OrganizationSelectorInput!) {
organization(selector: $selector) {
organization {
...OrganizationLayout_OrganizationFragment
...SubscriptionPage_OrganizationFragment
}
}
...SubscriptionPage_QueryFragment
}
`);
function SubscriptionPage(): ReactElement {
return (
<>
<Title title="Subscription & Usage" />
<OrganizationLayout value="subscription" query={SubscriptionPageQuery}>
{props =>
props.organization ? (
<Page organization={props.organization.organization} query={props} />
) : null
}
</OrganizationLayout>
</>
);
}
export const getServerSideProps = withSessionProtection(async context => {
/**
* If Strive is not enabled we redirect the user to the organization.
*/
const isStripeEnabled = getIsStripeEnabled();
if (!isStripeEnabled) {
const parts = String(context.resolvedUrl).split('/');
parts.pop();
return {
redirect: {
destination: parts.join('/'),
permanent: false,
},
};
}
return { props: {} };
});
export default authenticated(SubscriptionPage);

View file

@ -28,14 +28,11 @@ export default class MyDocument extends Document<{
dangerouslySetInnerHTML={{
__html:
// we setup background via style tag to prevent white flash on initial page loading
'html {background: #0b0d11}',
'html {background: #030711}',
}}
/>
<link rel="preconnect" href="https://fonts.gstatic.com" />
<link
href="https://fonts.googleapis.com/css2?family=Inter:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap"
rel="stylesheet"
/>
<link rel="preconnect" href="https://rsms.me/" />
<link rel="stylesheet" href="https://rsms.me/inter/inter.css" />
<link rel="icon" href="/just-logo.svg" type="image/svg+xml" />
<script
type="module"

View file

@ -1,20 +1,18 @@
import { ReactElement } from 'react';
import { authenticated } from '@/components/authenticated-container';
import { SubHeader, Title } from '@/components/v2';
import { MetaTitle } from '@/components/v2';
import { CreateOrganizationForm } from '@/components/v2/modals/create-organization';
import { withSessionProtection } from '@/lib/supertokens/guard';
function CreateOrgPage(): ReactElement {
return (
<>
<Title title="Create Organization" />
<SubHeader>
<div className="flex items-center">
<div className="container w-1/3">
<CreateOrganizationForm />
</div>
<MetaTitle title="Create Organization" />
<div className="h-full grow flex items-center">
<div className="container w-1/3">
<CreateOrganizationForm />
</div>
</SubHeader>
</div>
</>
);
}

View file

@ -3,7 +3,7 @@ import { useFormik } from 'formik';
import { useMutation, useQuery } from 'urql';
import * as Yup from 'yup';
import { authenticated } from '@/components/authenticated-container';
import { Avatar, Button, Heading, Input, SubHeader, Tabs, Title } from '@/components/v2';
import { Avatar, Button, Heading, Input, MetaTitle, Tabs } from '@/components/v2';
import { graphql } from '@/gql';
import { MeDocument } from '@/graphql';
import { withSessionProtection } from '@/lib/supertokens/guard';
@ -51,26 +51,24 @@ function SettingsPage(): ReactElement {
return (
<>
<Title title="Profile settings" />
<SubHeader>
<header className="container flex items-center pb-5">
<div className="mr-4 rounded-full">
<Avatar
src={null}
alt="Your profile photo"
shape="circle"
fallback={me?.displayName[0] ?? '?'}
className="!h-[94px] !w-[94px] text-4xl"
/>
</div>
<div className="overflow-hidden">
<Heading size="2xl" className="line-clamp-1">
{me?.displayName}
</Heading>
<span className="text-xs font-medium text-gray-500">{me?.email}</span>
</div>
</header>
</SubHeader>
<MetaTitle title="Profile settings" />
<header className="container flex items-center pb-5">
<div className="mr-4 rounded-full">
<Avatar
src={null}
alt="Your profile photo"
shape="circle"
fallback={me?.displayName[0] ?? '?'}
className="!h-[94px] !w-[94px] text-4xl"
/>
</div>
<div className="overflow-hidden">
<Heading size="2xl" className="line-clamp-1">
{me?.displayName}
</Heading>
<span className="text-xs font-medium text-gray-500">{me?.email}</span>
</div>
</header>
<Tabs defaultValue="personal-info" className="container">
<Tabs.List>
<Tabs.Trigger value="personal-info">Personal Info</Tabs.Trigger>

View file

@ -2,6 +2,91 @@
@tailwind components;
@tailwind utilities;
/* <shadcdn */
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--popover: 222.86 70% 3.92%;
--popover-foreground: 0 0% 100%;
--card: 0 0% 100%;
--card-foreground: 222.2 47.4% 11.2%;
--border: 240 3.7% 15.88%;
--input: 214.3 31.8% 91.4%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--accent: 0 0% 9.02%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 100% 50%;
--destructive-foreground: 210 40% 98%;
--ring: 215 20.2% 65.1%;
--radius: 0.5rem;
}
.dark {
--background: 220, 21.43%, 5.49%;
--foreground: 213 31% 91%;
--muted: 24 9.8% 10%;
--muted-foreground: 25 5.26% 44.71%;
--popover: 222.86 70% 3.92%;
--popover-foreground: 0 0% 98%;
--card: 224 71% 4%;
--card-foreground: 213 31% 91%;
--border: 12 5% 15%;
--input: 12 5% 15%;
--primary: 210 40% 98%; /* 40 89% 60%; */
--primary-foreground: 222.2 47.4% 1.2%;
/* 221.54 28.89% 8.82% */
/* 12 5 15 */
--secondary: 230 14% 8%;
--secondary-foreground: 210 40% 98%; /* 220, 21.43%, 5.49% */
--accent: 230 14% 8%;
--accent-foreground: 210 40% 98%; /* 220, 21.43%, 5.49% */
--destructive: 0 63% 31%;
--destructive-foreground: 210 40% 98%; /* 220, 21.43%, 5.49% */
--ring: 216 34% 17%;
--radius: 0.5rem;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
font-feature-settings: 'rlig' 1, 'calt' 1;
}
}
/* shadcdn> */
:root {
color-scheme: dark;
}
@ -19,7 +104,7 @@
}
#__next {
font-family: inherit !important;
/* font-family: inherit !important; */
color: inherit !important;
@apply flex h-full flex-col text-sm text-gray-700 antialiased lg:text-base;
}

View file

@ -2,7 +2,6 @@ import { ReactElement, useEffect } from 'react';
import { useRouter } from 'next/router';
import Session, { SessionAuth } from 'supertokens-auth-react/recipe/session';
import { HiveStripeWrapper } from '@/lib/billing/stripe';
import { Header } from './v2';
/**
* Utility for wrapping a component with an authenticated container that has the default application layout.
@ -35,7 +34,7 @@ export const authenticated =
return (
<SessionAuth>
<HiveStripeWrapper>
<Header />
{/* <Header /> */}
<Component {...props} />
</HiveStripeWrapper>
</SessionAuth>

View file

@ -1,12 +1,12 @@
import { ReactElement, ReactNode } from 'react';
import clsx from 'clsx';
import { Drawer } from '@/components/v2';
import { DocumentType, graphql } from '@/gql';
import { DocumentType, FragmentType, graphql, useFragment } from '@/gql';
import { getDocsUrl } from '@/lib/docs-url';
import { useToggle } from '@/lib/hooks';
import { CheckCircledIcon } from '@radix-ui/react-icons';
const GetStartedWizard_GetStartedProgress = graphql(`
export const GetStartedWizard_GetStartedProgress = graphql(`
fragment GetStartedWizard_GetStartedProgress on OrganizationGetStarted {
creatingProject
publishingSchema
@ -17,12 +17,11 @@ const GetStartedWizard_GetStartedProgress = graphql(`
}
`);
export function GetStartedProgress({
tasks,
}: {
tasks: DocumentType<typeof GetStartedWizard_GetStartedProgress>;
export function GetStartedProgress(props: {
tasks: FragmentType<typeof GetStartedWizard_GetStartedProgress>;
}): ReactElement | null {
const [isOpen, toggle] = useToggle();
const tasks = useFragment(GetStartedWizard_GetStartedProgress, props.tasks);
if (!tasks) {
return null;

View file

@ -1,3 +0,0 @@
export { OrganizationLayout } from './organization';
export { ProjectLayout } from './project';
export { TargetLayout } from './target';

View file

@ -1,14 +1,12 @@
import { ReactElement, ReactNode, useEffect } from 'react';
import { ReactElement, ReactNode } from 'react';
import NextLink from 'next/link';
import { useRouter } from 'next/router';
import cookies from 'js-cookie';
import { TypedDocumentNode, useQuery } from 'urql';
import { Button, Heading, SubHeader, Tabs } from '@/components/v2';
import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select';
import { UserMenu } from '@/components/ui/user-menu';
import { HiveLink, Tabs } from '@/components/v2';
import { PlusIcon } from '@/components/v2/icon';
import { CreateProjectModal } from '@/components/v2/modals';
import { LAST_VISITED_ORG_KEY } from '@/constants';
import { FragmentType, graphql, useFragment } from '@/gql';
import { Exact } from '@/graphql';
import {
canAccessOrganization,
OrganizationAccessScope,
@ -27,156 +25,182 @@ enum TabValue {
Subscription = 'subscription',
}
const OrganizationLayout_OrganizationFragment = graphql(`
fragment OrganizationLayout_OrganizationFragment on Organization {
const OrganizationLayout_CurrentOrganizationFragment = graphql(`
fragment OrganizationLayout_CurrentOrganizationFragment on Organization {
id
name
cleanId
me {
...CanAccessOrganization_MemberFragment
}
...ProPlanBilling_OrganizationFragment
...RateLimitWarn_OrganizationFragment
...UserMenu_CurrentOrganizationFragment
}
`);
export function OrganizationLayout<
TSatisfiesType extends {
organization?:
| {
organization?: FragmentType<typeof OrganizationLayout_OrganizationFragment> | null;
}
| null
| undefined;
},
>({
const OrganizationLayout_MeFragment = graphql(`
fragment OrganizationLayout_MeFragment on User {
id
...UserMenu_MeFragment
}
`);
const OrganizationLayout_OrganizationConnectionFragment = graphql(`
fragment OrganizationLayout_OrganizationConnectionFragment on OrganizationConnection {
nodes {
id
cleanId
name
}
...UserMenu_OrganizationConnectionFragment
}
`);
export function OrganizationLayout({
children,
value,
query,
className,
...props
}: {
children(
props: TSatisfiesType,
selector: {
organization: string;
},
): ReactNode;
value?: 'overview' | 'members' | 'settings' | 'subscription' | 'policy';
className?: string;
query: TypedDocumentNode<
TSatisfiesType,
Exact<{
selector: {
organization: string;
};
}>
>;
me: FragmentType<typeof OrganizationLayout_MeFragment> | null;
currentOrganization: FragmentType<typeof OrganizationLayout_CurrentOrganizationFragment> | null;
organizations: FragmentType<typeof OrganizationLayout_OrganizationConnectionFragment> | null;
children: ReactNode;
}): ReactElement | null {
const router = useRouteSelector();
const { push } = useRouter();
const [isModalOpen, toggleModalOpen] = useToggle();
const orgId = router.organizationId;
const [organizationQuery] = useQuery({
query,
variables: {
selector: {
organization: orgId,
},
},
});
const organization = useFragment(
OrganizationLayout_OrganizationFragment,
organizationQuery.data?.organization?.organization,
const currentOrganization = useFragment(
OrganizationLayout_CurrentOrganizationFragment,
props.currentOrganization,
);
useEffect(() => {
if (organizationQuery.error) {
cookies.remove(LAST_VISITED_ORG_KEY);
// url with # provoke error Maximum update depth exceeded
void push('/404', router.asPath.replace(/#.*/, ''));
}
}, [organizationQuery.error, router]);
useOrganizationAccess({
member: organization?.me ?? null,
member: currentOrganization?.me ?? null,
scope: OrganizationAccessScope.Read,
redirect: true,
});
if (organizationQuery.fetching || organizationQuery.error) {
return null;
}
const me = organization?.me;
if (!organization || !me) {
return null;
}
if (!value) {
return (
<>
{children(organizationQuery.data!, {
organization: orgId,
})}
</>
);
}
const meInCurrentOrg = currentOrganization?.me;
const me = useFragment(OrganizationLayout_MeFragment, props.me);
const organizationConnection = useFragment(
OrganizationLayout_OrganizationConnectionFragment,
props.organizations,
);
const organizations = organizationConnection?.nodes;
return (
<>
<SubHeader>
<header>
<div className="container flex h-[84px] items-center justify-between">
<div className="truncate">
<Heading size="2xl" className="inline">
{organization?.name}
</Heading>
<div className="text-xs font-medium text-gray-500">Organization</div>
<div className="flex flex-row items-center gap-4">
<HiveLink className="w-8 h-8" />
{currentOrganization && organizations ? (
<Select
defaultValue={currentOrganization.cleanId}
onValueChange={id => {
router.visitOrganization({
organizationId: id,
});
}}
>
<SelectTrigger variant="default">
<div className="font-medium" data-cy="organization-picker-current">
{currentOrganization.name}
</div>
</SelectTrigger>
<SelectContent>
{organizations.map(org => (
<SelectItem key={org.cleanId} value={org.cleanId}>
{org.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<div className="w-48 h-5 bg-gray-800 rounded-full animate-pulse" />
)}
</div>
<div>
<UserMenu
me={me ?? null}
currentOrganization={currentOrganization ?? null}
organizations={organizationConnection ?? null}
/>
</div>
<Button size="large" variant="primary" className="shrink-0" onClick={toggleModalOpen}>
Create Project
<PlusIcon className="ml-2" />
</Button>
<CreateProjectModal isOpen={isModalOpen} toggleModalOpen={toggleModalOpen} />
</div>
</SubHeader>
<Tabs className="container" value={value}>
<Tabs.List>
<Tabs.Trigger value={TabValue.Overview} asChild>
<NextLink href={`/${orgId}`}>Overview</NextLink>
</Tabs.Trigger>
{canAccessOrganization(OrganizationAccessScope.Members, me) && (
<Tabs.Trigger value={TabValue.Members} asChild>
<NextLink href={`/${orgId}/view/${TabValue.Members}`}>Members</NextLink>
</Tabs.Trigger>
</header>
<div className="relative border-b border-gray-800">
<div className="container flex justify-between items-center">
{currentOrganization && meInCurrentOrg ? (
<Tabs value={value}>
<Tabs.List>
<Tabs.Trigger value={TabValue.Overview} asChild>
<NextLink href={`/${currentOrganization.cleanId}`}>Overview</NextLink>
</Tabs.Trigger>
{canAccessOrganization(OrganizationAccessScope.Members, meInCurrentOrg) && (
<Tabs.Trigger value={TabValue.Members} asChild>
<NextLink href={`/${currentOrganization.cleanId}/view/${TabValue.Members}`}>
Members
</NextLink>
</Tabs.Trigger>
)}
{canAccessOrganization(OrganizationAccessScope.Settings, meInCurrentOrg) && (
<>
<Tabs.Trigger value={TabValue.Policy} asChild>
<NextLink href={`/${currentOrganization.cleanId}/view/${TabValue.Policy}`}>
Policy
</NextLink>
</Tabs.Trigger>
<Tabs.Trigger value={TabValue.Settings} asChild>
<NextLink href={`/${currentOrganization.cleanId}/view/${TabValue.Settings}`}>
Settings
</NextLink>
</Tabs.Trigger>
</>
)}
{getIsStripeEnabled() &&
canAccessOrganization(OrganizationAccessScope.Settings, meInCurrentOrg) && (
<Tabs.Trigger value={TabValue.Subscription} asChild>
<NextLink
href={`/${currentOrganization.cleanId}/view/${TabValue.Subscription}`}
>
Subscription
</NextLink>
</Tabs.Trigger>
)}
</Tabs.List>
</Tabs>
) : (
<div className="flex flex-row gap-x-8 px-4 py-3 border-b-[2px] border-b-transparent">
<div className="w-12 h-5 bg-gray-800 rounded-full animate-pulse" />
<div className="w-12 h-5 bg-gray-800 rounded-full animate-pulse" />
<div className="w-12 h-5 bg-gray-800 rounded-full animate-pulse" />
</div>
)}
{canAccessOrganization(OrganizationAccessScope.Settings, me) && (
{currentOrganization ? (
<>
<Tabs.Trigger value={TabValue.Policy} asChild>
<NextLink href={`/${orgId}/view/${TabValue.Policy}`}>Policy</NextLink>
</Tabs.Trigger>
<Tabs.Trigger value={TabValue.Settings} asChild>
<NextLink href={`/${orgId}/view/${TabValue.Settings}`}>Settings</NextLink>
</Tabs.Trigger>
<Button onClick={toggleModalOpen} variant="link" className="text-orange-500">
<PlusIcon size={16} className="mr-2" />
New project
</Button>
<CreateProjectModal isOpen={isModalOpen} toggleModalOpen={toggleModalOpen} />
</>
)}
{getIsStripeEnabled() && canAccessOrganization(OrganizationAccessScope.Settings, me) && (
<Tabs.Trigger value={TabValue.Subscription} asChild>
<NextLink href={`/${orgId}/view/${TabValue.Subscription}`}>Subscription</NextLink>
</Tabs.Trigger>
)}
</Tabs.List>
<Tabs.Content value={value}>
<RateLimitWarn organization={organization} />
<ProPlanBilling organization={organization} />
<div className={className}>
{children(organizationQuery.data!, {
organization: orgId,
})}
</div>
</Tabs.Content>
</Tabs>
) : null}
</div>
</div>
<div className="container pb-7">
{currentOrganization ? (
<>
<ProPlanBilling organization={currentOrganization} />
<RateLimitWarn organization={currentOrganization} />
</>
) : null}
<div className={className}>{children}</div>
</div>
</>
);
}

View file

@ -1,17 +1,12 @@
import { ReactElement, ReactNode, useEffect } from 'react';
import { ReactElement, ReactNode } from 'react';
import NextLink from 'next/link';
import { TypedDocumentNode, useQuery } from 'urql';
import { Button, Heading, Link, SubHeader, Tabs } from '@/components/v2';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/v2/dropdown';
import { ArrowDownIcon, TargetIcon } from '@/components/v2/icon';
import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select';
import { UserMenu } from '@/components/ui/user-menu';
import { HiveLink, Tabs } from '@/components/v2';
import { PlusIcon } from '@/components/v2/icon';
import { CreateTargetModal } from '@/components/v2/modals';
import { FragmentType, graphql, useFragment } from '@/gql';
import { Exact, ProjectsDocument } from '@/graphql';
import { canAccessProject, ProjectAccessScope, useProjectAccess } from '@/lib/access/project';
import { useRouteSelector, useToggle } from '@/lib/hooks';
import { ProjectMigrationToast } from '../project/migration-toast';
@ -23,217 +18,204 @@ enum TabValue {
Settings = 'settings',
}
const ProjectLayout_OrganizationFragment = graphql(`
fragment ProjectLayout_OrganizationFragment on Organization {
const ProjectLayout_CurrentOrganizationFragment = graphql(`
fragment ProjectLayout_CurrentOrganizationFragment on Organization {
id
name
cleanId
me {
...CanAccessProject_MemberFragment
}
...UserMenu_CurrentOrganizationFragment
projects {
...ProjectLayout_ProjectConnectionFragment
}
}
`);
const ProjectLayout_ProjectFragment = graphql(`
fragment ProjectLayout_ProjectFragment on Project {
const ProjectLayout_MeFragment = graphql(`
fragment ProjectLayout_MeFragment on User {
id
...UserMenu_MeFragment
}
`);
const ProjectLayout_OrganizationConnectionFragment = graphql(`
fragment ProjectLayout_OrganizationConnectionFragment on OrganizationConnection {
nodes {
id
cleanId
name
}
...UserMenu_OrganizationConnectionFragment
}
`);
const ProjectLayout_CurrentProjectFragment = graphql(`
fragment ProjectLayout_CurrentProjectFragment on Project {
id
cleanId
name
type
registryModel
}
`);
export function ProjectLayout<
/**
* LOL fire me for this.
* Because of this kind of abstraction in place I invented this complicated generic satisfaction thing.
* I'm not sure if it's worth it, but it's the only way I could think of to make it work without removing this component.
*/
TSatisfiesType extends {
organization?:
| {
organization?: FragmentType<typeof ProjectLayout_OrganizationFragment> | null;
}
| null
| undefined;
project?: FragmentType<typeof ProjectLayout_ProjectFragment> | null;
},
>({
const ProjectLayout_ProjectConnectionFragment = graphql(`
fragment ProjectLayout_ProjectConnectionFragment on ProjectConnection {
nodes {
id
cleanId
name
}
}
`);
export function ProjectLayout({
children,
value,
className,
query,
...props
}: {
children(
props: {
project: Exclude<TSatisfiesType['project'], null | undefined>;
organization: Exclude<TSatisfiesType['organization'], null | undefined>;
},
selector: {
organization: string;
project: string;
},
): ReactNode;
value: 'targets' | 'alerts' | 'settings' | 'policy';
className?: string;
query: TypedDocumentNode<
TSatisfiesType,
Exact<{
organizationId: string;
projectId: string;
}>
>;
me: FragmentType<typeof ProjectLayout_MeFragment> | null;
currentOrganization: FragmentType<typeof ProjectLayout_CurrentOrganizationFragment> | null;
currentProject: FragmentType<typeof ProjectLayout_CurrentProjectFragment> | null;
organizations: FragmentType<typeof ProjectLayout_OrganizationConnectionFragment> | null;
children: ReactNode;
}): ReactElement | null {
const router = useRouteSelector();
const [isModalOpen, toggleModalOpen] = useToggle();
const { organizationId: orgId, projectId } = router;
const [projectQuery] = useQuery({
query,
variables: {
organizationId: orgId,
projectId,
},
});
useEffect(() => {
if (projectQuery.error) {
// url with # provoke error Maximum update depth exceeded
void router.push('/404', router.asPath.replace(/#.*/, ''));
}
}, [projectQuery.error, router]);
const [projectsQuery] = useQuery({
query: ProjectsDocument,
variables: {
selector: {
organization: orgId,
},
},
});
const organization = useFragment(
ProjectLayout_OrganizationFragment,
projectQuery.data?.organization?.organization,
const currentOrganization = useFragment(
ProjectLayout_CurrentOrganizationFragment,
props.currentOrganization,
);
const currentProject = useFragment(ProjectLayout_CurrentProjectFragment, props.currentProject);
useProjectAccess({
scope: ProjectAccessScope.Read,
member: organization?.me ?? null,
member: currentOrganization?.me ?? null,
redirect: true,
});
if (projectQuery.fetching || projectQuery.error) {
return null;
}
const project = useFragment(ProjectLayout_ProjectFragment, projectQuery.data?.project);
const projects = projectsQuery.data?.projects;
if (!organization || !project) {
return null;
}
const me = useFragment(ProjectLayout_MeFragment, props.me);
const organizationConnection = useFragment(
ProjectLayout_OrganizationConnectionFragment,
props.organizations,
);
const projectConnection = useFragment(
ProjectLayout_ProjectConnectionFragment,
currentOrganization?.projects ?? null,
);
const projects = projectConnection?.nodes;
return (
<>
<SubHeader>
<div className="container flex items-center pb-4">
<div>
{organization && (
<Link
href={`/${orgId}`}
className="line-clamp-1 flex max-w-[250px] items-center text-xs font-medium text-gray-500"
>
{organization.name}
</Link>
<header>
<div className="container flex h-[84px] items-center justify-between">
<div className="flex flex-row items-center gap-4">
<HiveLink className="w-8 h-8" />
{currentOrganization ? (
<NextLink href={`/${orgId}`} className="shrink-0 font-medium truncate max-w-[200px]">
{currentOrganization.name}
</NextLink>
) : (
<div className="w-48 max-w-[200px] h-5 bg-gray-800 rounded-full animate-pulse" />
)}
{projects?.length && currentProject ? (
<>
<div className="text-gray-500 italic">/</div>
<Select
defaultValue={currentProject.cleanId}
onValueChange={id => {
router.visitProject({
organizationId: orgId,
projectId: id,
});
}}
>
<SelectTrigger variant="default">
<div className="font-medium">{currentProject.name}</div>
</SelectTrigger>
<SelectContent>
{projects.map(project => (
<SelectItem key={project.cleanId} value={project.cleanId}>
{project.name}
</SelectItem>
))}
</SelectContent>
</Select>
</>
) : (
<div className="w-48 h-5 bg-gray-800 rounded-full animate-pulse" />
)}
<div className="flex items-center gap-2.5">
<Heading size="2xl" className="line-clamp-1 max-w-2xl">
{project?.name}
</Heading>
{projects && projects.total > 1 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="small">
<ArrowDownIcon className="h-5 w-5 text-gray-500" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent sideOffset={5} align="end">
{projects.nodes.map(
node =>
node.cleanId !== projectId && (
<NextLink
key={node.cleanId}
href={`/${orgId}/${node.cleanId}`}
className="line-clamp-1 max-w-2xl"
>
<DropdownMenuItem>{node.name}</DropdownMenuItem>
</NextLink>
),
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
<span className="text-xs font-bold text-[#34eab9]">{project?.type}</span>
</div>
<Button
className="ml-auto shrink-0"
variant="primary"
size="large"
onClick={toggleModalOpen}
>
New Target
<TargetIcon className="ml-6" />
</Button>
<CreateTargetModal isOpen={isModalOpen} toggleModalOpen={toggleModalOpen} />
<div>
<UserMenu
me={me ?? null}
currentOrganization={currentOrganization ?? null}
organizations={organizationConnection ?? null}
/>
</div>
</div>
</SubHeader>
</header>
{value === 'settings' || project.registryModel !== 'LEGACY' ? null : (
{value === 'settings' || currentProject?.registryModel !== 'LEGACY' ? null : (
<ProjectMigrationToast orgId={orgId} projectId={projectId} />
)}
<Tabs className="container" value={value}>
<Tabs.List>
<Tabs.Trigger value={TabValue.Targets} asChild>
<NextLink href={`/${orgId}/${projectId}`}>Targets</NextLink>
</Tabs.Trigger>
{canAccessProject(ProjectAccessScope.Alerts, organization.me) && (
<Tabs.Trigger value={TabValue.Alerts} asChild>
<NextLink href={`/${orgId}/${projectId}/view/${TabValue.Alerts}`}>Alerts</NextLink>
</Tabs.Trigger>
<div className="relative border-b border-gray-800">
<div className="container flex justify-between items-center">
{currentOrganization ? (
<Tabs value={value}>
<Tabs.List>
<Tabs.Trigger value={TabValue.Targets} asChild>
<NextLink href={`/${orgId}/${projectId}`}>Targets</NextLink>
</Tabs.Trigger>
{canAccessProject(ProjectAccessScope.Alerts, currentOrganization.me) && (
<Tabs.Trigger value={TabValue.Alerts} asChild>
<NextLink href={`/${orgId}/${projectId}/view/${TabValue.Alerts}`}>
Alerts
</NextLink>
</Tabs.Trigger>
)}
{canAccessProject(ProjectAccessScope.Settings, currentOrganization.me) && (
<>
<Tabs.Trigger value={TabValue.Policy} asChild>
<NextLink href={`/${orgId}/${projectId}/view/${TabValue.Policy}`}>
Policy
</NextLink>
</Tabs.Trigger>
<Tabs.Trigger value={TabValue.Settings} asChild>
<NextLink href={`/${orgId}/${projectId}/view/${TabValue.Settings}`}>
Settings
</NextLink>
</Tabs.Trigger>
</>
)}
</Tabs.List>
</Tabs>
) : (
<div className="flex flex-row gap-x-8 px-4 py-3 border-b-[2px] border-b-transparent">
<div className="w-12 h-5 bg-gray-800 rounded-full animate-pulse" />
<div className="w-12 h-5 bg-gray-800 rounded-full animate-pulse" />
<div className="w-12 h-5 bg-gray-800 rounded-full animate-pulse" />
</div>
)}
{canAccessProject(ProjectAccessScope.Settings, organization.me) && (
<>
<Tabs.Trigger value={TabValue.Policy} asChild>
<NextLink href={`/${orgId}/${projectId}/view/${TabValue.Policy}`}>Policy</NextLink>
</Tabs.Trigger>
<Tabs.Trigger value={TabValue.Settings} asChild>
<NextLink href={`/${orgId}/${projectId}/view/${TabValue.Settings}`}>
Settings
</NextLink>
</Tabs.Trigger>
</>
)}
</Tabs.List>
<Tabs.Content value={value} className={className}>
{children(
{
project: projectQuery.data?.project as Exclude<
TSatisfiesType['project'],
null | undefined
>,
organization: projectQuery.data?.organization as Exclude<
TSatisfiesType['organization'],
null | undefined
>,
},
{
organization: orgId,
project: projectId,
},
)}
</Tabs.Content>
</Tabs>
{currentProject ? (
<Button onClick={toggleModalOpen} variant="link" className="text-orange-500">
<PlusIcon size={16} className="mr-2" />
New target
</Button>
) : null}
<CreateTargetModal isOpen={isModalOpen} toggleModalOpen={toggleModalOpen} />
</div>
</div>
<div className="container pb-7 h-full">
<div className={className}>{children}</div>
</div>
</>
);
}

View file

@ -1,20 +1,15 @@
import { ReactElement, ReactNode, useEffect } from 'react';
import { ReactElement, ReactNode } from 'react';
import NextLink from 'next/link';
import { TypedDocumentNode, useQuery } from 'urql';
import { Button, Heading, Link, SubHeader, Tabs } from '@/components/v2';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/v2/dropdown';
import { ArrowDownIcon } from '@/components/v2/icon';
import { Link } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select';
import { UserMenu } from '@/components/ui/user-menu';
import { HiveLink, Tabs } from '@/components/v2';
import { ConnectSchemaModal } from '@/components/v2/modals';
import { FragmentType, graphql, useFragment } from '@/gql';
import { canAccessTarget, TargetAccessScope, useTargetAccess } from '@/lib/access/target';
import { useRouteSelector, useToggle } from '@/lib/hooks';
import { Link1Icon } from '@radix-ui/react-icons';
import { QueryError } from '../common/DataWrapper';
import { cn } from '@/lib/utils';
import { ProjectMigrationToast } from '../project/migration-toast';
enum TabValue {
@ -27,20 +22,48 @@ enum TabValue {
Settings = 'settings',
}
export const TargetLayout_OrganizationFragment = graphql(`
fragment TargetLayout_OrganizationFragment on Organization {
const TargetLayout_CurrentOrganizationFragment = graphql(`
fragment TargetLayout_CurrentOrganizationFragment on Organization {
id
name
cleanId
me {
...CanAccessTarget_MemberFragment
}
name
...UserMenu_CurrentOrganizationFragment
projects {
...ProjectLayout_ProjectConnectionFragment
}
}
`);
const TargetLayout_ProjectFragment = graphql(`
fragment TargetLayout_ProjectFragment on Project {
type
const TargetLayout_MeFragment = graphql(`
fragment TargetLayout_MeFragment on User {
id
...UserMenu_MeFragment
}
`);
const TargetLayout_OrganizationConnectionFragment = graphql(`
fragment TargetLayout_OrganizationConnectionFragment on OrganizationConnection {
nodes {
id
cleanId
name
}
...UserMenu_OrganizationConnectionFragment
}
`);
const TargetLayout_CurrentProjectFragment = graphql(`
fragment TargetLayout_CurrentProjectFragment on Project {
id
cleanId
name
registryModel
targets {
...TargetLayout_TargetConnectionFragment
}
}
`);
@ -60,199 +83,205 @@ const TargetLayout_IsCDNEnabledFragment = graphql(`
}
`);
export const TargetLayout = <
TSatisfies extends {
organization?:
| {
organization?: FragmentType<typeof TargetLayout_OrganizationFragment> | null;
}
| null
| undefined;
project?: FragmentType<typeof TargetLayout_ProjectFragment> | null;
targets: FragmentType<typeof TargetLayout_TargetConnectionFragment>;
} & FragmentType<typeof TargetLayout_IsCDNEnabledFragment>,
>({
export const TargetLayout = ({
children,
connect,
value,
className,
query,
...props
}: {
children(props: TSatisfies): ReactNode;
value: 'schema' | 'explorer' | 'checks' | 'history' | 'operations' | 'laboratory' | 'settings';
className?: string;
children: ReactNode;
connect?: ReactNode;
query: TypedDocumentNode<
TSatisfies,
{
organizationId: string;
projectId: string;
targetId: string;
}
>;
me: FragmentType<typeof TargetLayout_MeFragment> | null;
currentOrganization: FragmentType<typeof TargetLayout_CurrentOrganizationFragment> | null;
currentProject: FragmentType<typeof TargetLayout_CurrentProjectFragment> | null;
organizations: FragmentType<typeof TargetLayout_OrganizationConnectionFragment> | null;
isCDNEnabled: FragmentType<typeof TargetLayout_IsCDNEnabledFragment> | null;
}): ReactElement | null => {
const router = useRouteSelector();
const [isModalOpen, toggleModalOpen] = useToggle();
const router = useRouteSelector();
const { organizationId: orgId, projectId, targetId } = router;
const { organizationId: orgId, projectId } = router;
const [data] = useQuery({
query,
variables: {
organizationId: orgId,
projectId,
targetId,
},
requestPolicy: 'cache-and-network',
});
const currentOrganization = useFragment(
TargetLayout_CurrentOrganizationFragment,
props.currentOrganization,
);
const currentProject = useFragment(TargetLayout_CurrentProjectFragment, props.currentProject);
const isCDNEnabled = useFragment(TargetLayout_IsCDNEnabledFragment, data.data);
const targets = useFragment(TargetLayout_TargetConnectionFragment, data.data?.targets);
const target = targets?.nodes.find(node => node.cleanId === targetId);
const org = useFragment(TargetLayout_OrganizationFragment, data.data?.organization?.organization);
const project = useFragment(TargetLayout_ProjectFragment, data.data?.project);
const me = org?.me;
useEffect(() => {
if (!data.fetching && !target) {
// url with # provoke error Maximum update depth exceeded
void router.push('/404', router.asPath.replace(/#.*/, ''));
}
}, [router, target, data.fetching]);
useEffect(() => {
if (!data.fetching && !project) {
// url with # provoke error Maximum update depth exceeded
void router.push('/404', router.asPath.replace(/#.*/, ''));
}
}, [router, project, data.fetching]);
const me = useFragment(TargetLayout_MeFragment, props.me);
const organizationConnection = useFragment(
TargetLayout_OrganizationConnectionFragment,
props.organizations,
);
const targetConnection = useFragment(
TargetLayout_TargetConnectionFragment,
currentProject?.targets,
);
const targets = targetConnection?.nodes;
const currentTarget = targets?.find(target => target.cleanId === router.targetId);
const isCDNEnabled = useFragment(TargetLayout_IsCDNEnabledFragment, props.isCDNEnabled);
useTargetAccess({
scope: TargetAccessScope.Read,
member: me ?? null,
member: currentOrganization?.me ?? null,
redirect: true,
});
const canAccessSchema = canAccessTarget(TargetAccessScope.RegistryRead, me ?? null);
const canAccessSettings = canAccessTarget(TargetAccessScope.Settings, me ?? null);
if (data.fetching) {
return null;
}
if (data.error) {
return <QueryError error={data.error} />;
}
if (!org || !project || !target) {
return null;
}
const canAccessSchema = canAccessTarget(
TargetAccessScope.RegistryRead,
currentOrganization?.me ?? null,
);
const canAccessSettings = canAccessTarget(
TargetAccessScope.Settings,
currentOrganization?.me ?? null,
);
return (
<>
<SubHeader>
<div className="container flex items-center">
<div>
<div className="flex items-center text-xs font-medium text-gray-500">
<Link href={`/${orgId}`} className="line-clamp-1 max-w-[250px]">
{org.name}
</Link>
<ArrowDownIcon className="mx-1 h-4 w-4 -rotate-90 stroke-[1px]" />
<Link href={`/${orgId}/${projectId}`} className="line-clamp-1 max-w-[250px]">
{project.name}
</Link>
</div>
<div className="flex items-center gap-2.5">
<Heading size="2xl" className="line-clamp-1 max-w-2xl">
{target?.name}
</Heading>
{targets && targets.total > 1 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="small">
<ArrowDownIcon className="h-5 w-5 text-gray-500" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent sideOffset={5} align="end">
{targets.nodes.map(
node =>
node.cleanId !== targetId && (
<NextLink
key={node.cleanId}
href={`/${orgId}/${projectId}/${node.cleanId}`}
className="line-clamp-1 max-w-2xl"
>
<DropdownMenuItem>{node.name}</DropdownMenuItem>
</NextLink>
),
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
<div className="mb-10 text-xs font-bold text-[#34eab9]">{project?.type}</div>
</div>
{connect ??
(isCDNEnabled?.isCDNEnabled ? (
<header>
<div className="container flex h-[84px] items-center justify-between">
<div className="flex flex-row items-center gap-4">
<HiveLink className="w-8 h-8" />
{currentOrganization ? (
<NextLink href={`/${orgId}`} className="shrink-0 font-medium truncate max-w-[200px]">
{currentOrganization.name}
</NextLink>
) : (
<div className="w-48 max-w-[200px] h-5 bg-gray-800 rounded-full animate-pulse" />
)}
<div className="text-gray-500 italic">/</div>
{currentProject ? (
<NextLink
href={`/${orgId}/${projectId}`}
className="shrink-0 font-medium truncate max-w-[200px]"
>
{currentProject.name}
</NextLink>
) : (
<div className="w-48 max-w-[200px] h-5 bg-gray-800 rounded-full animate-pulse" />
)}
<div className="text-gray-500 italic">/</div>
{targets?.length && currentOrganization && currentProject && currentTarget ? (
<>
<Button
size="large"
variant="primary"
onClick={toggleModalOpen}
className="ml-auto"
<Select
defaultValue={currentTarget.cleanId}
onValueChange={id => {
router.visitTarget({
organizationId: currentOrganization.cleanId,
projectId: currentProject.cleanId,
targetId: id,
});
}}
>
Connect to CDN
<Link1Icon className="ml-8 h-6 w-auto" />
</Button>
<ConnectSchemaModal isOpen={isModalOpen} toggleModalOpen={toggleModalOpen} />
<SelectTrigger variant="default">
<div className="font-medium">{currentTarget.name}</div>
</SelectTrigger>
<SelectContent>
{targets.map(target => (
<SelectItem key={target.cleanId} value={target.cleanId}>
{target.name}
</SelectItem>
))}
</SelectContent>
</Select>
</>
) : null)}
) : (
<div className="w-48 max-w-[200px] h-5 bg-gray-800 rounded-full animate-pulse" />
)}
</div>
<div>
<UserMenu
me={me ?? null}
currentOrganization={currentOrganization ?? null}
organizations={organizationConnection ?? null}
/>
</div>
</div>
</SubHeader>
</header>
{project.registryModel === 'LEGACY' ? (
{currentProject?.registryModel === 'LEGACY' ? (
<ProjectMigrationToast orgId={orgId} projectId={projectId} />
) : null}
<Tabs className="container flex h-full grow flex-col" value={value}>
<Tabs.List>
{canAccessSchema && (
<>
<Tabs.Trigger value={TabValue.Schema} asChild>
<NextLink href={`/${orgId}/${projectId}/${targetId}`}>Schema</NextLink>
</Tabs.Trigger>
<Tabs.Trigger value={TabValue.Checks} asChild>
<NextLink href={`/${orgId}/${projectId}/${targetId}/checks`}>Checks</NextLink>
</Tabs.Trigger>
<Tabs.Trigger value={TabValue.Explorer} asChild>
<NextLink href={`/${orgId}/${projectId}/${targetId}/explorer`}>Explorer</NextLink>
</Tabs.Trigger>
<Tabs.Trigger value={TabValue.History} asChild>
<NextLink href={`/${orgId}/${projectId}/${targetId}/history`}>History</NextLink>
</Tabs.Trigger>
<Tabs.Trigger value={TabValue.Operations} asChild>
<NextLink href={`/${orgId}/${projectId}/${targetId}/operations`}>
Operations
</NextLink>
</Tabs.Trigger>
<Tabs.Trigger value={TabValue.Laboratory} asChild>
<NextLink href={`/${orgId}/${projectId}/${targetId}/laboratory`}>
Laboratory
</NextLink>
</Tabs.Trigger>
</>
<div className="relative border-b border-gray-800">
<div className="container flex justify-between items-center">
{currentTarget ? (
<Tabs className="flex h-full grow flex-col" value={value}>
<Tabs.List>
{canAccessSchema && (
<>
<Tabs.Trigger value={TabValue.Schema} asChild>
<NextLink href={`/${orgId}/${projectId}/${currentTarget.cleanId}`}>
Schema
</NextLink>
</Tabs.Trigger>
<Tabs.Trigger value={TabValue.Checks} asChild>
<NextLink href={`/${orgId}/${projectId}/${currentTarget.cleanId}/checks`}>
Checks
</NextLink>
</Tabs.Trigger>
<Tabs.Trigger value={TabValue.Explorer} asChild>
<NextLink href={`/${orgId}/${projectId}/${currentTarget.cleanId}/explorer`}>
Explorer
</NextLink>
</Tabs.Trigger>
<Tabs.Trigger value={TabValue.History} asChild>
<NextLink href={`/${orgId}/${projectId}/${currentTarget.cleanId}/history`}>
History
</NextLink>
</Tabs.Trigger>
<Tabs.Trigger value={TabValue.Operations} asChild>
<NextLink href={`/${orgId}/${projectId}/${currentTarget.cleanId}/operations`}>
Operations
</NextLink>
</Tabs.Trigger>
<Tabs.Trigger value={TabValue.Laboratory} asChild>
<NextLink href={`/${orgId}/${projectId}/${currentTarget.cleanId}/laboratory`}>
Laboratory
</NextLink>
</Tabs.Trigger>
</>
)}
{canAccessSettings && (
<Tabs.Trigger value={TabValue.Settings} asChild>
<NextLink href={`/${orgId}/${projectId}/${currentTarget.cleanId}/settings`}>
Settings
</NextLink>
</Tabs.Trigger>
)}
</Tabs.List>
</Tabs>
) : (
<div className="flex flex-row gap-x-8 px-4 py-3 border-b-[2px] border-b-transparent">
<div className="w-12 h-5 bg-gray-800 rounded-full animate-pulse" />
<div className="w-12 h-5 bg-gray-800 rounded-full animate-pulse" />
<div className="w-12 h-5 bg-gray-800 rounded-full animate-pulse" />
</div>
)}
{canAccessSettings && (
<Tabs.Trigger value={TabValue.Settings} asChild>
<NextLink href={`/${orgId}/${projectId}/${targetId}/settings`}>Settings</NextLink>
</Tabs.Trigger>
)}
</Tabs.List>
<Tabs.Content value={value} className={className}>
{children(data.data as TSatisfies)}
</Tabs.Content>
</Tabs>
{currentTarget ? (
// eslint-disable-next-line unicorn/no-negated-condition
connect != null ? (
connect
) : isCDNEnabled ? (
<>
<Button onClick={toggleModalOpen} variant="link" className="text-orange-500">
<Link size={16} className="mr-2" />
Connect to CDN
</Button>
<ConnectSchemaModal isOpen={isModalOpen} toggleModalOpen={toggleModalOpen} />
</>
) : null
) : null}
</div>
</div>
<div className="container pb-7 h-full">
<div className={cn('flex justify-between gap-12', className)}>
<div className="grow">{children}</div>
</div>
</div>
</>
);
};

View file

@ -1,9 +1,10 @@
import { ReactElement } from 'react';
import type { JSONSchema } from 'json-schema-typed';
import { Checkbox } from '@/components/ui/checkbox';
import { Markdown } from '@/components/v2/markdown';
import { FragmentType, graphql, useFragment } from '@/gql';
import { RuleInstanceSeverityLevel } from '@/graphql';
import { Checkbox, DocsLink, Tooltip } from '../v2';
import { DocsLink, Tooltip } from '../v2';
import { useConfigurationHelper } from './form-helper';
import { PolicyRuleConfig } from './rules-configuration';
import { SeverityLevelToggle } from './rules-configuration/severity-toggle';
@ -73,7 +74,7 @@ export function PolicyListItem(props: {
</>
}
>
<label htmlFor={ruleInfo.id} className="font-mono font-bold">
<label htmlFor={ruleInfo.id} className="font-mono text-sm font-medium">
{ruleInfo.id}
</label>
</Tooltip>

View file

@ -1,6 +1,7 @@
import { ReactElement, useMemo, useRef } from 'react';
import { Formik, FormikHelpers, FormikProps } from 'formik';
import { useQuery } from 'urql';
import { Button } from '@/components/ui/button';
import { FragmentType, graphql, useFragment } from '@/gql';
import {
PolicySettings_SchemaPolicyFragmentFragment,
@ -8,7 +9,7 @@ import {
SchemaPolicyInput,
} from '@/graphql';
import type { ResultOf } from '@graphql-typed-document-node/core';
import { Button, Callout, DataWrapper } from '../v2';
import { Callout, DataWrapper } from '../v2';
import { PolicyListItem } from './policy-list-item';
import { buildValidationSchema, PolicyFormValues } from './rules-configuration';
@ -108,7 +109,7 @@ function PolicySettingsListForm({
<Button
disabled={!props.dirty || saving}
type="submit"
variant="primary"
variant="default"
onClick={() => props.submitForm()}
>
Update Policy

View file

@ -0,0 +1,51 @@
import { Checkbox, Table, TBody, Td, Tr } from '@/components/v2';
import { FragmentType, graphql, useFragment } from '@/gql';
export const AlertsTable_AlertFragment = graphql(`
fragment AlertsTable_AlertFragment on Alert {
id
type
channel {
id
name
type
}
target {
id
cleanId
name
}
}
`);
export function AlertsTable(props: {
alerts: FragmentType<typeof AlertsTable_AlertFragment>[];
isChecked: (alertId: string) => boolean;
onCheckedChange: (alertId: string, checked: boolean) => void;
}) {
const alerts = useFragment(AlertsTable_AlertFragment, props.alerts);
return (
<Table>
<TBody>
{alerts.map(alert => (
<Tr key={alert.id}>
<Td width="1">
<Checkbox
onCheckedChange={isChecked => {
props.onCheckedChange(alert.id, isChecked === true);
}}
checked={props.isChecked(alert.id)}
/>
</Td>
<Td>
<span className="capitalize">{alert.type.replaceAll('_', ' ').toLowerCase()}</span>
</Td>
<Td>Channel: {alert.channel.name}</Td>
<Td>Target: {alert.target.name}</Td>
</Tr>
))}
</TBody>
</Table>
);
}

View file

@ -0,0 +1,57 @@
import { Checkbox, Table, Tag, TBody, Td, Tr } from '@/components/v2';
import { FragmentType, graphql, useFragment } from '@/gql';
import { AlertChannelType } from '@/gql/graphql';
export const ChannelsTable_AlertChannelFragment = graphql(`
fragment ChannelsTable_AlertChannelFragment on AlertChannel {
id
name
type
... on AlertSlackChannel {
channel
}
... on AlertWebhookChannel {
endpoint
}
}
`);
export function ChannelsTable(props: {
channels: FragmentType<typeof ChannelsTable_AlertChannelFragment>[];
isChecked: (channelId: string) => boolean;
onCheckedChange: (channelId: string, checked: boolean) => void;
}) {
const channels = useFragment(ChannelsTable_AlertChannelFragment, props.channels);
return (
<Table>
<TBody>
{channels.map(channel => (
<Tr key={channel.id}>
<Td width="1">
<Checkbox
onCheckedChange={isChecked => {
props.onCheckedChange(channel.id, isChecked === true);
}}
checked={props.isChecked(channel.id)}
/>
</Td>
<Td>{channel.name}</Td>
<Td className="text-xs truncate text-gray-400">
{channel.__typename === 'AlertSlackChannel'
? channel.channel
: channel.__typename === 'AlertWebhookChannel'
? channel.endpoint
: ''}
</Td>
<Td>
<Tag color={channel.type === AlertChannelType.Webhook ? 'green' : 'yellow'}>
{channel.type}
</Tag>
</Td>
</Tr>
))}
</TBody>
</Table>
);
}

View file

@ -1,43 +1,56 @@
import { ReactElement } from 'react';
import { useFormik } from 'formik';
import { useMutation, useQuery } from 'urql';
import { useMutation } from 'urql';
import * as Yup from 'yup';
import { Button, Heading, Modal, Select } from '@/components/v2';
import { AddAlertDocument, AlertChannelsDocument, AlertType, TargetsDocument } from '@/graphql';
import { FragmentType, graphql, useFragment } from '@/gql';
import { AlertType } from '@/graphql';
import { useRouteSelector } from '@/lib/hooks';
export const CreateAlertModal = ({
isOpen,
toggleModalOpen,
}: {
export const CreateAlertModal_AddAlertMutation = graphql(`
mutation CreateAlertModal_AddAlertMutation($input: AddAlertInput!) {
addAlert(input: $input) {
ok {
updatedProject {
id
}
addedAlert {
...AlertsTable_AlertFragment
}
}
error {
message
}
}
}
`);
export const CreateAlertModal_TargetFragment = graphql(`
fragment CreateAlertModal_TargetFragment on Target {
id
cleanId
name
}
`);
export const CreateAlertModal_AlertChannelFragment = graphql(`
fragment CreateAlertModal_AlertChannelFragment on AlertChannel {
id
name
}
`);
export const CreateAlertModal = (props: {
isOpen: boolean;
toggleModalOpen: () => void;
targets: FragmentType<typeof CreateAlertModal_TargetFragment>[];
channels: FragmentType<typeof CreateAlertModal_AlertChannelFragment>[];
}): ReactElement => {
const [mutation, mutate] = useMutation(AddAlertDocument);
const { isOpen, toggleModalOpen } = props;
const targets = useFragment(CreateAlertModal_TargetFragment, props.targets);
const channels = useFragment(CreateAlertModal_AlertChannelFragment, props.channels);
const [mutation, mutate] = useMutation(CreateAlertModal_AddAlertMutation);
const router = useRouteSelector();
const [targetsQuery] = useQuery({
query: TargetsDocument,
variables: {
selector: {
organization: router.organizationId,
project: router.projectId,
},
},
requestPolicy: 'cache-and-network',
});
const [channelsQuery] = useQuery({
query: AlertChannelsDocument,
variables: {
selector: {
organization: router.organizationId,
project: router.projectId,
},
},
requestPolicy: 'cache-and-network',
});
const channels = channelsQuery.data?.alertChannels || [];
const targets = targetsQuery.data?.targets.nodes || [];
const { handleSubmit, values, handleChange, errors, touched, isSubmitting } = useFormik({
initialValues: {
@ -61,7 +74,7 @@ export const CreateAlertModal = ({
),
}),
async onSubmit(values) {
const { error } = await mutate({
const { error, data } = await mutate({
input: {
organization: router.organizationId,
project: router.projectId,
@ -70,7 +83,8 @@ export const CreateAlertModal = ({
type: values.type,
},
});
if (!error) {
console.log({ data, error });
if (!error && data?.addAlert) {
toggleModalOpen();
}
},

View file

@ -7,13 +7,15 @@ import { graphql } from '@/gql';
import { AlertChannelType } from '@/graphql';
import { useRouteSelector } from '@/lib/hooks';
const CreateChannel_AddAlertChannelMutation = graphql(`
export const CreateChannel_AddAlertChannelMutation = graphql(`
mutation CreateChannel_AddAlertChannel($input: AddAlertChannelInput!) {
addAlertChannel(input: $input) {
ok {
updatedProject {
id
}
addedAlertChannel {
...AlertSlackChannelFields
...AlertWebhookChannelFields
...ChannelsTable_AlertChannelFragment
}
}
error {
@ -60,7 +62,7 @@ export const CreateChannelModal = ({
),
}),
async onSubmit(values) {
const { data } = await mutate({
const { data, error } = await mutate({
input: {
organization: router.organizationId,
project: router.projectId,
@ -71,6 +73,13 @@ export const CreateChannelModal = ({
values.type === AlertChannelType.Webhook ? { endpoint: values.endpoint } : null,
},
});
if (error) {
console.error(error);
}
if (data?.addAlertChannel.error) {
console.log('local error');
console.error(data.addAlertChannel.error);
}
if (data?.addAlertChannel.ok) {
toggleModalOpen();
}

View file

@ -0,0 +1,51 @@
import { useMutation } from 'urql';
import { Button } from '@/components/ui/button';
import { graphql } from '@/gql';
export const DeleteAlertsButton_DeleteAlertsMutation = graphql(`
mutation DeleteAlertsButton_DeleteAlertsMutation($input: DeleteAlertsInput!) {
deleteAlerts(input: $input) {
ok {
updatedProject {
id
}
}
error {
message
}
}
}
`);
export function DeleteAlertsButton({
selected,
onSuccess,
organizationId,
projectId,
}: {
selected: string[];
onSuccess(): void;
organizationId: string;
projectId: string;
}) {
const [mutation, mutate] = useMutation(DeleteAlertsButton_DeleteAlertsMutation);
return (
<Button
variant="destructive"
disabled={selected.length === 0 || mutation.fetching}
onClick={async () => {
await mutate({
input: {
organization: organizationId,
project: projectId,
alerts: selected,
},
});
onSuccess();
}}
>
Delete {selected.length || null}
</Button>
);
}

View file

@ -0,0 +1,51 @@
import { useMutation } from 'urql';
import { Button } from '@/components/ui/button';
import { graphql } from '@/gql';
export const DeleteChannelsButton_DeleteChannelsMutation = graphql(`
mutation DeleteChannelsButton_DeleteChannelsMutation($input: DeleteAlertChannelsInput!) {
deleteAlertChannels(input: $input) {
ok {
updatedProject {
id
}
}
error {
message
}
}
}
`);
export function DeleteChannelsButton({
selected,
onSuccess,
organizationId,
projectId,
}: {
selected: string[];
onSuccess(): void;
organizationId: string;
projectId: string;
}) {
const [mutation, mutate] = useMutation(DeleteChannelsButton_DeleteChannelsMutation);
return (
<Button
variant="destructive"
disabled={selected.length === 0 || mutation.fetching}
onClick={async () => {
await mutate({
input: {
organization: organizationId,
project: projectId,
channels: selected,
},
});
onSuccess();
}}
>
Delete {selected.length || null}
</Button>
);
}

View file

@ -179,17 +179,16 @@ const GraphQLTypeCard_SupergraphMetadataFragment = graphql(`
}
`);
export function GraphQLTypeCard(
props: React.PropsWithChildren<{
kind: string;
name: string;
description?: string | null;
implements?: string[];
totalRequests?: number;
usage?: FragmentType<typeof SchemaExplorerUsageStats_UsageFragment>;
supergraphMetadata?: FragmentType<typeof GraphQLTypeCard_SupergraphMetadataFragment> | null;
}>,
): ReactElement {
export function GraphQLTypeCard(props: {
kind: string;
name: string;
description?: string | null;
implements?: string[];
totalRequests?: number;
usage?: FragmentType<typeof SchemaExplorerUsageStats_UsageFragment>;
supergraphMetadata?: FragmentType<typeof GraphQLTypeCard_SupergraphMetadataFragment> | null;
children: ReactNode;
}): ReactElement {
const supergraphMetadata = useFragment(
GraphQLTypeCard_SupergraphMetadataFragment,
props.supergraphMetadata,

View file

@ -88,9 +88,9 @@ export function SchemaExplorerFilter({
);
return (
<div className="flex flex-row items-center lg:gap-12 gap-4">
<div className="flex flex-row items-center gap-x-4">
<Autocomplete
className="grow"
className="grow min-w-[250px]"
placeholder="Search for a type"
defaultValue={typename ? { value: typename, label: typename } : null}
options={types}

View file

@ -37,9 +37,11 @@ type SchemaExplorerContextType = {
isArgumentListCollapsed: boolean;
setArgumentListCollapsed(isCollapsed: boolean): void;
setPeriodOption(option: PeriodOption): void;
setDataRetentionInDays(days: number): void;
periodOption: PeriodOption;
availablePeriodOptions: PeriodOption[];
period: Period;
dataRetentionInDays: number;
};
const SchemaExplorerContext = createContext<SchemaExplorerContextType>({
@ -49,15 +51,14 @@ const SchemaExplorerContext = createContext<SchemaExplorerContextType>({
period: createPeriod('7d'),
availablePeriodOptions: ['7d'],
setPeriodOption: () => {},
dataRetentionInDays: 7,
setDataRetentionInDays: () => {},
});
export function SchemaExplorerProvider({
dataRetentionInDays,
children,
}: {
dataRetentionInDays: number;
children: ReactNode;
}): ReactElement {
export function SchemaExplorerProvider({ children }: { children: ReactNode }): ReactElement {
const [dataRetentionInDays, setDataRetentionInDays] = useState(
7 /* Minimum possible data retention period - Free plan */,
);
const [isArgumentListCollapsed, setArgumentListCollapsed] = useLocalStorage(
'hive:schema-explorer:collapsed',
true,
@ -98,6 +99,8 @@ export function SchemaExplorerProvider({
setPeriodOption: updatePeriod,
periodOption,
availablePeriodOptions,
dataRetentionInDays,
setDataRetentionInDays,
}}
>
{children}

View file

@ -21,114 +21,7 @@ import {
} from '@/lib/hooks';
import { useChartStyles } from '@/utils';
import { OperationsFallback } from './Fallback';
function resolutionToMilliseconds(
resolution: number,
period: {
from: string;
to: string;
},
) {
const distanceInMinutes =
(new Date(period.to).getTime() - new Date(period.from).getTime()) / 1000 / 60;
return Math.round(distanceInMinutes / resolution) * 1000 * 60;
}
/**
* Adds missing samples to left and right sides of the series. We end up with a smooooooth chart
*/
function fullSeries(
data: [string, number][],
interval: number,
period: {
from: string;
to: string;
},
): [string, number][] {
if (!data.length) {
return createEmptySeries({ interval, period });
}
// Find the first sample
const firstSample = new Date(data[0][0]).getTime();
// Find the last sample
const lastSample = new Date(data[data.length - 1][0]).getTime();
// Turn `period.from` to a number
const startAt = new Date(period.from).getTime();
// Turn `period.to` to a number
const endAt = new Date(period.to).getTime();
// Calculate the number missing steps by
// 1. comparing two dates (last/first sample and the expected boundary sample)
// 2. dividing by interval
// 3. rounding to floor int
const stepsToAddOnLeft = Math.floor(Math.abs(firstSample - startAt) / interval);
const stepsToAddOnRight = Math.floor(Math.abs(endAt - lastSample) / interval);
// Add n steps to the left side where each sample has its date decreased by i*interval based on the first sample
for (let i = 1; i <= stepsToAddOnLeft; i++) {
data.unshift([new Date(firstSample - i * interval).toISOString(), 0]);
}
// Add n steps to the right side where each sample has its date increased by i*interval based on the last sample
for (let i = 1; i <= stepsToAddOnRight; i++) {
data.push([new Date(lastSample + i * interval).toISOString(), 0]);
}
// Instead of creating a new array, we could move things around but this is easier
const newData: [string, number][] = [];
for (let i = 0; i < data.length; i++) {
const current = data[i];
const previous = data[i - 1];
if (previous) {
const currentTime = new Date(current[0]).getTime();
const previousTime = new Date(previous[0]).getTime();
const diff = currentTime - previousTime;
// We do subtract the interval to make sure we don't duplicate the current sample
const stepsToAdd = Math.floor(Math.abs(diff - interval) / interval);
if (stepsToAdd > 0) {
// We start with 1 because we already have one sample on the left side
for (let j = 1; j <= stepsToAdd; j++) {
newData.push([new Date(previousTime + j * interval).toISOString(), 0]);
}
}
}
newData.push(current);
}
return newData;
}
function times<T>(amount: number, f: (index: number) => T) {
const items: Array<T> = [];
for (let i = 0; i < amount; i++) {
items.push(f(i));
}
return items;
}
function createEmptySeries({
interval,
period,
}: {
interval: number;
period: {
from: string;
to: string;
};
}): [string, number][] {
const startAt = new Date(period.from).getTime();
const endAt = new Date(period.to).getTime();
const steps = Math.floor((endAt - startAt) / interval);
return times(steps, i => [new Date(startAt + i * interval).toISOString(), 0]);
}
import { createEmptySeries, fullSeries, resolutionToMilliseconds } from './utils';
const classes = {
root: clsx('text-center'),
@ -446,16 +339,19 @@ function ClientsStats({
<div className="w-full rounded-md bg-gray-900/50 p-5 border border-gray-800">
<Section.Title>Clients</Section.Title>
<Section.Subtitle>Top 5 - GraphQL API consumers</Section.Subtitle>
<AutoSizer disableHeight className="mt-5 w-full">
<AutoSizer disableHeight className="mt-5 w-full flex flex-row gap-x-4">
{size => {
if (!size.width) {
return <></>;
}
const gapX4 = 16;
const innerWidth = size.width - gapX4 * 2;
return (
<div className="flex w-full flex-row gap-4">
<>
<ReactECharts
style={{ width: size.width / 2, height: 200 }}
style={{ width: innerWidth / 2, height: 200 }}
option={{
...styles,
grid: {
@ -495,7 +391,7 @@ function ClientsStats({
}}
/>
<ReactECharts
style={{ width: size.width / 2, height: 200 }}
style={{ width: innerWidth / 2, height: 200 }}
option={{
...styles,
grid: {
@ -534,7 +430,7 @@ function ClientsStats({
],
}}
/>
</div>
</>
);
}}
</AutoSizer>

View file

@ -0,0 +1,107 @@
export function resolutionToMilliseconds(
resolution: number,
period: {
from: string;
to: string;
},
) {
const distanceInMinutes =
(new Date(period.to).getTime() - new Date(period.from).getTime()) / 1000 / 60;
return Math.round(distanceInMinutes / resolution) * 1000 * 60;
}
/**
* Adds missing samples to left and right sides of the series. We end up with a smooooooth chart
*/
export function fullSeries(
data: [string, number][],
interval: number,
period: {
from: string;
to: string;
},
): [string, number][] {
if (!data.length) {
return createEmptySeries({ interval, period });
}
// Find the first sample
const firstSample = new Date(data[0][0]).getTime();
// Find the last sample
const lastSample = new Date(data[data.length - 1][0]).getTime();
// Turn `period.from` to a number
const startAt = new Date(period.from).getTime();
// Turn `period.to` to a number
const endAt = new Date(period.to).getTime();
// Calculate the number missing steps by
// 1. comparing two dates (last/first sample and the expected boundary sample)
// 2. dividing by interval
// 3. rounding to floor int
const stepsToAddOnLeft = Math.floor(Math.abs(firstSample - startAt) / interval);
const stepsToAddOnRight = Math.floor(Math.abs(endAt - lastSample) / interval);
// Add n steps to the left side where each sample has its date decreased by i*interval based on the first sample
for (let i = 1; i <= stepsToAddOnLeft; i++) {
data.unshift([new Date(firstSample - i * interval).toISOString(), 0]);
}
// Add n steps to the right side where each sample has its date increased by i*interval based on the last sample
for (let i = 1; i <= stepsToAddOnRight; i++) {
data.push([new Date(lastSample + i * interval).toISOString(), 0]);
}
// Instead of creating a new array, we could move things around but this is easier
const newData: [string, number][] = [];
for (let i = 0; i < data.length; i++) {
const current = data[i];
const previous = data[i - 1];
if (previous) {
const currentTime = new Date(current[0]).getTime();
const previousTime = new Date(previous[0]).getTime();
const diff = currentTime - previousTime;
// We do subtract the interval to make sure we don't duplicate the current sample
const stepsToAdd = Math.floor(Math.abs(diff - interval) / interval);
if (stepsToAdd > 0) {
// We start with 1 because we already have one sample on the left side
for (let j = 1; j <= stepsToAdd; j++) {
newData.push([new Date(previousTime + j * interval).toISOString(), 0]);
}
}
}
newData.push(current);
}
return newData;
}
function times<T>(amount: number, f: (index: number) => T) {
const items: Array<T> = [];
for (let i = 0; i < amount; i++) {
items.push(f(i));
}
return items;
}
export function createEmptySeries({
interval,
period,
}: {
interval: number;
period: {
from: string;
to: string;
};
}): [string, number][] {
const startAt = new Date(period.from).getTime();
const endAt = new Date(period.to).getTime();
const steps = Math.floor((endAt - startAt) / interval);
return times(steps, i => [new Date(startAt + i * interval).toISOString(), 0]);
}

View file

@ -7,7 +7,6 @@ import {
Button,
Card,
DocsLink,
DocsNote,
Heading,
Input,
Modal,
@ -382,14 +381,14 @@ export function CDNAccessTokens(props: {
<Heading id="cdn-access-tokens" className="mb-2">
CDN Access Token
</Heading>
<DocsNote>
<div className="text-sm text-gray-400">
CDN Access Tokens are used to access to Hive High-Availability CDN and read your schema
artifacts.
<br />
<DocsLink href="/management/targets#cdn-access-tokens">
Learn more about CDN Access Tokens
</DocsLink>
</DocsNote>
</div>
{canManage && (
<div className="my-3.5 flex justify-between">
<Button

View file

@ -0,0 +1,47 @@
import React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
import { Slot } from '@radix-ui/react-slot';
const buttonVariants = cva(
'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none ring-offset-background',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline: 'border border-input hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'underline-offset-4 hover:underline text-primary',
},
size: {
default: 'h-10 py-2 px-4',
sm: 'h-9 px-3 rounded-md',
lg: 'h-11 px-8 rounded-md',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
return (
<Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
);
},
);
Button.displayName = 'Button';
export { Button, buttonVariants };

View file

@ -0,0 +1,56 @@
import React from 'react';
import { cn } from '@/lib/utils';
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('rounded-lg border bg-card text-card-foreground shadow-sm', className)}
{...props}
/>
),
);
Card.displayName = 'Card';
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
),
);
CardHeader.displayName = 'CardHeader';
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
// eslint-disable-next-line jsx-a11y/heading-has-content
<h3
ref={ref}
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
{...props}
/>
),
);
CardTitle.displayName = 'CardTitle';
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
));
CardDescription.displayName = 'CardDescription';
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
),
);
CardContent.displayName = 'CardContent';
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn(' flex items-center p-6 pt-0', className)} {...props} />
),
);
CardFooter.displayName = 'CardFooter';
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };

View file

@ -0,0 +1,25 @@
import React from 'react';
import { Check } from 'lucide-react';
import { cn } from '@/lib/utils';
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
'peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator className={cn('flex items-center justify-center text-current')}>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox };

View file

@ -0,0 +1,182 @@
import React from 'react';
import { Check, ChevronRight, Circle } from 'lucide-react';
import { cn } from '@/lib/utils';
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
opensToLeft?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent',
inset && 'pl-8',
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1',
className,
)}
{...props}
/>
));
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-2 text-popover-foreground shadow-md animate-in data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
active?: boolean;
}
>(({ className, inset, active, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm px-2 py-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
inset && 'pl-8',
active && 'bg-accent',
className,
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn('px-2 py-2 text-sm font-semibold', inset && 'pl-8', className)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-muted', className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span className={cn('ml-auto text-xs tracking-widest opacity-60', className)} {...props} />
);
};
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
};

View file

@ -0,0 +1,10 @@
import type { ReactNode } from 'react';
import { cn } from '@/lib/utils';
export function Title({ children, className }: { children: ReactNode; className?: string }) {
return <h3 className={cn('text-lg font-semibold tracking-tight', className)}>{children}</h3>;
}
export function Subtitle({ children, className }: { children: string; className?: string }) {
return <p className={cn('text-sm text-gray-400', className)}>{children}</p>;
}

View file

@ -0,0 +1,136 @@
import React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { Check, ChevronDown } from 'lucide-react';
import { cn } from '@/lib/utils';
import * as SelectPrimitive from '@radix-ui/react-select';
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
//
const selectVariants = cva(
'flex h-10 w-full bg-secondary items-center justify-between rounded-md px-3 py-2 text-sm disabled:cursor-not-allowed disabled:opacity-50',
{
variants: {
variant: {
default:
'border border-input ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
ghost: '',
},
},
defaultVariants: {
variant: 'default',
},
},
);
type SelectTriggerProps = React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> &
VariantProps<typeof selectVariants>;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
SelectTriggerProps
>(({ className, children, variant, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(selectVariants({ variant, className }))}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="ml-2 h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = 'popper', ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
'relative z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md animate-in fade-in-80',
position === 'popper' && 'translate-y-1',
className,
)}
position={position}
{...props}
>
<SelectPrimitive.Viewport
className={cn(
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]',
)}
>
{children}
</SelectPrimitive.Viewport>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn('py-1.5 pl-8 pr-2 text-sm font-semibold', className)}
{...props}
/>
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-muted', className)}
{...props}
/>
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
};

View file

@ -0,0 +1,27 @@
import React from 'react';
import { cn } from '@/lib/utils';
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-50 data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1',
className,
)}
{...props}
/>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };

View file

@ -0,0 +1,202 @@
import NextLink from 'next/link';
import { FaGithub, FaGoogle, FaKey } from 'react-icons/fa';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Avatar } from '@/components/v2';
import {
AlertTriangleIcon,
CalendarIcon,
FileTextIcon,
GraphQLIcon,
GridIcon,
LogOutIcon,
PlusIcon,
SettingsIcon,
TrendingUpIcon,
} from '@/components/v2/icon';
import { env } from '@/env/frontend';
import { FragmentType, graphql, useFragment } from '@/gql';
import { AuthProvider } from '@/graphql';
import { getDocsUrl } from '@/lib/docs-url';
import { cn } from '@/lib/utils';
import { GetStartedProgress } from '../get-started/wizard';
export const UserMenu_CurrentOrganizationFragment = graphql(`
fragment UserMenu_CurrentOrganizationFragment on Organization {
id
cleanId
name
getStarted {
...GetStartedWizard_GetStartedProgress
}
}
`);
export const UserMenu_OrganizationConnectionFragment = graphql(`
fragment UserMenu_OrganizationConnectionFragment on OrganizationConnection {
nodes {
id
cleanId
name
}
}
`);
export const UserMenu_MeFragment = graphql(`
fragment UserMenu_MeFragment on User {
id
email
fullName
displayName
provider
isAdmin
canSwitchOrganization
}
`);
export function UserMenu(props: {
me: FragmentType<typeof UserMenu_MeFragment> | null;
currentOrganization: FragmentType<typeof UserMenu_CurrentOrganizationFragment> | null;
organizations: FragmentType<typeof UserMenu_OrganizationConnectionFragment> | null;
}) {
const docsUrl = getDocsUrl();
const me = useFragment(UserMenu_MeFragment, props.me);
const currentOrganization = useFragment(
UserMenu_CurrentOrganizationFragment,
props.currentOrganization,
);
const organizations = useFragment(UserMenu_OrganizationConnectionFragment, props.organizations);
return (
<div className="flex flex-row gap-8 items-center">
{currentOrganization ? <GetStartedProgress tasks={currentOrganization.getStarted} /> : null}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div
className={cn('cursor-pointer', currentOrganization ? '' : 'animate-pulse')}
data-cy="user-menu-trigger"
>
<Avatar shape="circle" className="border-2 border-orange-900/50" />
</div>
</DropdownMenuTrigger>
{me && organizations ? (
<DropdownMenuContent sideOffset={5} align="end" className="min-w-[240px]">
<DropdownMenuLabel>
<div className="flex items-center justify-between">
<div className="flex flex-col space-y-1">
<div className="text-sm font-medium leading-none truncate">{me?.displayName}</div>
<div className="text-xs font-normal leading-none text-muted-foreground truncate">
{me?.email}
</div>
</div>
<div>
{me?.provider === AuthProvider.Google ? (
<FaGoogle title="Signed in using Google" />
) : me?.provider === AuthProvider.Github ? (
<FaGithub title="Signed in using Github" />
) : (
<FaKey title="Signed in using username and password" />
)}
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuSub>
{me?.canSwitchOrganization ? (
<DropdownMenuSubTrigger>
<GridIcon className="mr-2 h-4 w-4" />
Switch organization
</DropdownMenuSubTrigger>
) : null}
<DropdownMenuSubContent className="max-w-[300px]">
{organizations.nodes.length ? (
<DropdownMenuLabel>Organizations</DropdownMenuLabel>
) : null}
<DropdownMenuSeparator />
{organizations.nodes.map(org => (
<NextLink href={`/${org.cleanId}`} key={org.cleanId}>
<DropdownMenuItem active={currentOrganization?.cleanId === org.cleanId}>
{org.name}
</DropdownMenuItem>
</NextLink>
))}
<DropdownMenuSeparator />
<NextLink href="/org/new">
<DropdownMenuItem>
<PlusIcon className="mr-2 h-4 w-4" />
Create an organization
</DropdownMenuItem>
</NextLink>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuItem asChild>
<a
href="https://cal.com/team/the-guild/graphql-hive-15m"
target="_blank"
rel="noreferrer"
>
<CalendarIcon className="mr-2 h-4 w-4" />
Schedule a meeting
</a>
</DropdownMenuItem>
<NextLink href="/settings">
<DropdownMenuItem>
<SettingsIcon className="mr-2 h-4 w-4" />
Profile settings
</DropdownMenuItem>
<DropdownMenuSeparator />
</NextLink>
{docsUrl ? (
<DropdownMenuItem asChild>
<a href={docsUrl} target="_blank" rel="noreferrer">
<FileTextIcon className="mr-2 h-4 w-4" />
Documentation
</a>
</DropdownMenuItem>
) : null}
<DropdownMenuItem asChild>
<a href="https://status.graphql-hive.com" target="_blank" rel="noreferrer">
<AlertTriangleIcon className="mr-2 h-4 w-4" />
Status page
</a>
</DropdownMenuItem>
{me.isAdmin === true && (
<NextLink href="/manage">
<DropdownMenuItem>
<TrendingUpIcon className="mr-2 h-4 w-4" />
Manage Instance
</DropdownMenuItem>
</NextLink>
)}
{env.nodeEnv === 'development' && (
<NextLink href="/dev">
<DropdownMenuItem>
<GraphQLIcon className="mr-2 h-4 w-4" />
Dev GraphiQL
</DropdownMenuItem>
</NextLink>
)}
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<a href="/logout" data-cy="user-menu-logout">
<LogOutIcon className="mr-2 h-4 w-4" />
Log out
</a>
</DropdownMenuItem>
</DropdownMenuContent>
) : null}
</DropdownMenu>
</div>
);
}

View file

@ -1,14 +1,8 @@
import React, { ReactElement, ReactNode } from 'react';
import { ReactElement, ReactNode } from 'react';
import { useQuery } from 'urql';
import { ActivityNode } from '@/components/common/activities/common';
import { Heading, Link, Skeleton, TimeAgo } from '@/components/v2';
import {
ArrowDownIcon,
EditIcon,
PlusIcon,
TrashIcon,
UserPlusMinusIcon,
} from '@/components/v2/icon';
import { Link, TimeAgo } from '@/components/v2';
import { EditIcon, PlusIcon, TrashIcon, UserPlusMinusIcon } from '@/components/v2/icon';
import {
MemberDeletedActivity,
OrganizationActivitiesDocument,
@ -20,6 +14,7 @@ import {
} from '@/graphql';
import { fixDuplicatedFragments } from '@/lib/graphql';
import { useRouteSelector } from '@/lib/hooks';
import { Subtitle, Title } from '../ui/page';
const organizationActivitiesDocument = fixDuplicatedFragments(OrganizationActivitiesDocument);
@ -204,14 +199,14 @@ export const getActivity = (
}
};
export const Activities = (props: React.ComponentProps<'div'>): ReactElement => {
export const Activities = (): ReactElement => {
const router = useRouteSelector();
const [organizationActivitiesQuery] = useQuery({
query: organizationActivitiesDocument,
variables: {
selector: {
organization: router.organizationId,
limit: 10,
limit: 5,
},
},
requestPolicy: 'cache-and-network',
@ -221,42 +216,49 @@ export const Activities = (props: React.ComponentProps<'div'>): ReactElement =>
const isLoading = organizationActivitiesQuery.fetching;
return (
<div className="w-[450px] shrink-0" {...props}>
<Heading>Recent Activity</Heading>
<ul className="mt-4 w-full break-all rounded-md border border-gray-800 p-5">
<div className="w-[450px] shrink-0">
<div className="py-6">
<Title>Activity</Title>
<Subtitle>Recent changes in your organization</Subtitle>
</div>
<ul className="w-full break-all">
{isLoading || !activities?.nodes
? new Array(3).fill(null).map((_, index) => (
<ActivityContainer key={index}>
<Skeleton circle visible className="h-7 w-7 shrink-0" />
<div className="grow">
<Skeleton visible className="mb-2 h-3 w-2/5" />
<Skeleton visible className="h-3 w-full" />
<div className="flex justify-between items-center">
<div className="w-24 h-2 bg-gray-800 rounded-full animate-pulse" />
<div className="w-8 h-2 bg-gray-800 rounded-full animate-pulse" />
</div>
<div>
<div className="w-32 h-3 mt-4 bg-gray-800 rounded-full animate-pulse" />
</div>
</div>
</ActivityContainer>
))
: activities.nodes.map(activity => {
const { content, icon } = getActivity(activity);
const { content } = getActivity(activity);
return (
<ActivityContainer key={activity.id}>
<>
<div className="self-center p-1">{icon}</div>
<div className="grow">
{'project' in activity && !!activity.project && (
<h3 className="mb-1 flex items-center font-medium">
<span className="line-clamp-1">{activity.project.name}</span>
{'target' in activity && !!activity.target && (
<>
<ArrowDownIcon className="h-4 w-4 shrink-0 -rotate-90 select-none" />
<span className="line-clamp-1">{activity.target.name}</span>
</>
)}
</h3>
<div className="flex justify-between items-center">
<h3 className="mb-1 flex items-center font-medium">
<span className="line-clamp-1">{activity.project.name}</span>
{'target' in activity && !!activity.target && (
<>
<span className="italic mx-2">/</span>
<span className="line-clamp-1">{activity.target.name}</span>
</>
)}
</h3>
<TimeAgo date={activity.createdAt} className="float-right text-xs" />
</div>
)}
<div>
<span className="text-sm text-[#c4c4c4]">{content}</span>
&nbsp;
<TimeAgo date={activity.createdAt} className="float-right text-xs" />
</div>
</div>
</>

View file

@ -26,7 +26,7 @@ export const Avatar = ({
<Root
className={clsx(
// By default Root has `span` element with `display: inline` property
'flex shrink-0 items-center justify-center overflow-hidden bg-gray-800',
'flex shrink-0 items-center justify-center overflow-hidden bg-gray-900',
shape === 'square' ? (size === 'lg' ? 'rounded-md' : 'rounded-sm') : 'rounded-full',
{
xs: 'h-5 w-5',

View file

@ -1,6 +1,6 @@
import { forwardRef, ReactNode } from 'react';
import NextLink, { LinkProps } from 'next/link';
import { clsx } from 'clsx';
import { cn } from '@/lib/utils';
type CardProps = (
| {
@ -23,7 +23,7 @@ export const Card = forwardRef<HTMLDivElement, CardProps>(
<TagToUse
// @ts-expect-error TODO: figure out what's wrong with ref here
ref={forwardedRef}
className={clsx('rounded-md p-5 border border-gray-800', className)}
className={cn('rounded-md p-5 border border-gray-800', className)}
{...props}
>
{children}

View file

@ -1,20 +1,24 @@
import { ReactElement } from 'react';
import clsx from 'clsx';
import { Book } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { getDocsUrl } from '@/lib/docs-url';
import { ExclamationTriangleIcon, ExternalLinkIcon, InfoCircledIcon } from '@radix-ui/react-icons';
import { cn } from '@/lib/utils';
import { ExternalLinkIcon } from '@radix-ui/react-icons';
import { Link } from './link';
export const DocsNote = ({ children, warn }: { warn?: boolean; children: React.ReactNode }) => {
return (
<div className="flex my-2">
<div className="items-center align-middle pr-2 flex">
<div
className={cn('flex my-2 border-l-2 px-4 py-2', warn ? 'border-orange-500' : 'border-white')}
>
{/* <div className="items-center align-middle pr-2 flex flex-row">
{warn ? (
<ExclamationTriangleIcon className="text-orange-500" />
) : (
<InfoCircledIcon className="text-current" />
)}
</div>
<div className="grow text-gray-500 text-sm align-middle">{children}</div>
</div> */}
<div className="grow text-white text-sm align-middle">{children}</div>
</div>
);
};
@ -35,14 +39,12 @@ export const DocsLink = ({
: getDocsUrl(href) || 'https://docs.graphql-hive.com/';
return (
<Link
className={clsx('text-orange-500', className)}
href={fullUrl}
target="_blank"
rel="noreferrer"
>
{children}
{icon ?? <ExternalLinkIcon className="inline pl-1" />}
</Link>
<Button variant="link" className={cn('p-0 text-orange-500', className)} asChild>
<Link href={fullUrl} target="_blank" rel="noreferrer">
{icon ?? <Book className="mr-2 w-4 h-4" />}
{children}
<ExternalLinkIcon className="inline pl-1" />
</Link>
</Button>
);
};

View file

@ -45,11 +45,14 @@ export const DropdownMenuContent = React.forwardRef<
flex-col
gap-1
rounded-md
bg-gray-800
p-[13px]
text-sm
font-semibold
font-normal
bg-[#0b0d11]
text-gray-300
shadow-lg
shadow-black
ring-1 ring-gray-900
`,
className,
)}
@ -70,8 +73,7 @@ export const DropdownMenuSubTrigger = React.forwardRef<
{...props}
className={clsx(
`
radix-state-open:bg-orange-500/40
radix-state-open:sepia
radix-state-open:bg-gray-800/50
flex
cursor-pointer
items-center
@ -80,9 +82,9 @@ export const DropdownMenuSubTrigger = React.forwardRef<
py-2.5
px-2
transition
hover:bg-gray-500/50
hover:bg-gray-800/50
hover:text-white
focus:bg-gray-500/50
focus:bg-gray-800/50
focus:text-white
`,
className,
@ -108,11 +110,14 @@ export const DropdownMenuSubContent = React.forwardRef<
flex-col
gap-1
rounded-md
bg-gray-800
p-[13px]
text-sm
font-semibold
font-normal
bg-[#0b0d11]
text-gray-300
shadow-lg
shadow-black
ring-1 ring-gray-900
`,
className,
)}
@ -144,9 +149,9 @@ export const DropdownMenuItem = React.forwardRef<
py-2.5
px-2
transition
hover:bg-gray-500/50
hover:bg-gray-800/50
hover:text-white
focus:bg-gray-500/50
focus:bg-gray-800/50
focus:text-white`,
className,
)}
@ -168,7 +173,7 @@ export const DropdownMenuSeparator = React.forwardRef<
`
my-2
h-px
bg-gray-700/50
bg-gray-800/50
`,
className,
)}

View file

@ -1,19 +1,22 @@
import { ReactElement } from 'react';
import Image from 'next/image';
import { Card, DocsLink, Heading } from '@/components/v2/index';
import { cn } from '@/lib/utils';
import magnifier from '../../../public/images/figures/magnifier.svg';
export const EmptyList = ({
title,
description,
docsUrl,
className,
}: {
title: string;
description: string;
docsUrl?: string;
docsUrl?: string | null;
className?: string;
}): ReactElement => {
return (
<Card className="flex grow flex-col items-center gap-y-2" data-cy="empty-list">
<Card className={cn('flex grow flex-col items-center gap-y-2', className)} data-cy="empty-list">
<Image
src={magnifier}
alt="Magnifier illustration"

View file

@ -1,187 +0,0 @@
import { ReactElement, useEffect, useState } from 'react';
import NextLink from 'next/link';
import clsx from 'clsx';
import { useQuery } from 'urql';
import { GetStartedProgress } from '@/components/get-started/wizard';
import { Avatar, Button, HiveLink } from '@/components/v2';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '@/components/v2/dropdown';
import {
AlertTriangleIcon,
ArrowDownIcon,
CalendarIcon,
FileTextIcon,
GraphQLIcon,
GridIcon,
LogOutIcon,
PlusIcon,
SettingsIcon,
TrendingUpIcon,
} from '@/components/v2/icon';
import { env } from '@/env/frontend';
import { MeDocument, OrganizationsDocument } from '@/graphql';
import { getDocsUrl } from '@/lib/docs-url';
import { useRouteSelector } from '@/lib/hooks';
export function Header(): ReactElement {
const router = useRouteSelector();
const [meQuery] = useQuery({ query: MeDocument });
const [organizationsQuery] = useQuery({ query: OrganizationsDocument });
const [isOpaque, setIsOpaque] = useState(false);
const me = meQuery.data?.me;
const organizations = organizationsQuery.data?.organizations.nodes || [];
const currentOrg =
typeof router.organizationId === 'string'
? organizations.find(org => org.cleanId === router.organizationId)
: null;
// Copied from tailwindcss website
// https://github.com/tailwindlabs/tailwindcss.com/blob/94971856747c159b4896621c3308bcfa629bb736/src/components/Header.js#L149
useEffect(() => {
const offset = 30;
const onScroll = () => {
if (!isOpaque && window.scrollY > offset) {
setIsOpaque(true);
} else if (isOpaque && window.scrollY <= offset) {
setIsOpaque(false);
}
};
window.addEventListener('scroll', onScroll, { passive: true });
return () => {
window.removeEventListener('scroll', onScroll);
};
}, [isOpaque]);
const docsUrl = getDocsUrl();
return (
<header
className={clsx(
'fixed top-0 z-40 w-full border-b border-b-transparent transition',
isOpaque && 'border-b-gray-900 bg-black/80 backdrop-blur',
)}
>
<div className="container flex h-[84px] items-center justify-between">
<HiveLink />
<div className="flex flex-row gap-8">
{currentOrg ? <GetStartedProgress tasks={currentOrg.getStarted} /> : null}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button>
<ArrowDownIcon className="h-5 w-5 text-gray-500" />
<Avatar shape="circle" className="border-2 border-gray-900" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent sideOffset={5} align="end">
<DropdownMenuLabel className="mb-2 w-64 px-2 truncate">
{me?.displayName}
</DropdownMenuLabel>
<DropdownMenuSub>
{me?.canSwitchOrganization ? (
<DropdownMenuSubTrigger>
<GridIcon className="h-5 w-5" />
Switch organization
<ArrowDownIcon className="ml-10 -rotate-90 text-white" />
</DropdownMenuSubTrigger>
) : null}
<DropdownMenuSubContent sideOffset={25} className="max-w-[300px]">
{organizations.length ? (
<DropdownMenuLabel className="px-2 mb-2 text-xs font-bold text-gray-500 truncate !block">
ORGANIZATIONS
</DropdownMenuLabel>
) : null}
{organizations.map(org => (
<NextLink href={`/${org.cleanId}`} key={org.cleanId}>
<DropdownMenuItem
className={`truncate !block ${
currentOrg?.name === org.name
? 'bg-gray-700 text-gray-100 pointer-events-none'
: ''
}`}
>
{org.name}
</DropdownMenuItem>
</NextLink>
))}
<DropdownMenuSeparator />
<NextLink href="/org/new">
<DropdownMenuItem>
<PlusIcon className="h-5 w-5" />
Create an organization
</DropdownMenuItem>
</NextLink>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuItem asChild>
<a
href="https://cal.com/team/the-guild/graphql-hive-15m"
target="_blank"
rel="noreferrer"
>
<CalendarIcon className="h-5 w-5" />
Schedule a meeting
</a>
</DropdownMenuItem>
<NextLink href="/settings">
<DropdownMenuItem>
<SettingsIcon className="h-5 w-5" />
Profile settings
</DropdownMenuItem>
</NextLink>
{docsUrl ? (
<DropdownMenuItem asChild>
<a href={docsUrl} target="_blank" rel="noreferrer">
<FileTextIcon className="h-5 w-5" />
Documentation
</a>
</DropdownMenuItem>
) : null}
<DropdownMenuItem asChild>
<a href="https://status.graphql-hive.com" target="_blank" rel="noreferrer">
<AlertTriangleIcon className="h-5 w-5" />
Status page
</a>
</DropdownMenuItem>
{meQuery.data?.me?.isAdmin && (
<NextLink href="/manage">
<DropdownMenuItem>
<TrendingUpIcon className="h-5 w-5" />
Manage Instance
</DropdownMenuItem>
</NextLink>
)}
{env.nodeEnv === 'development' && (
<NextLink href="/dev">
<DropdownMenuItem>
<GraphQLIcon className="h-5 w-5" />
Dev GraphiQL
</DropdownMenuItem>
</NextLink>
)}
<DropdownMenuItem asChild>
<a href="/logout">
<LogOutIcon className="h-5 w-5" />
Log out
</a>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</header>
);
}

View file

@ -2,53 +2,11 @@ import { ReactElement } from 'react';
import NextLink from 'next/link';
import clsx from 'clsx';
import { HiveLogo } from '@/components/v2/icon';
import { useRouteSelector } from '@/lib/hooks';
export const HiveLink = ({ className }: { className?: string }): ReactElement => {
const router = useRouteSelector();
const orgId = router.organizationId;
return (
<NextLink
href={
// orgId can be undefined on /404 route
orgId ? `/${orgId}` : '/'
}
className={clsx('inline-flex items-center focus:ring', className)}
>
<NextLink href="/" className={clsx('inline-flex items-center', className)}>
<HiveLogo />
<div className="ml-2.5">
<svg
width="73"
height="18"
viewBox="0 0 73 18"
xmlns="http://www.w3.org/2000/svg"
className="mb-[5px] fill-[#fcfcfc]"
>
<path d="M11.4746 0.349487V7.01549H4.47859V0.349487H0.100586V17.5095H4.47859V11.1295H11.4746V17.5095H15.8746V0.349487H11.4746Z" />
<path d="M23.583 0.349487V17.5095H27.983V0.349487H23.583Z" />
<path d="M45.6447 17.5095L52.5967 0.349487H47.8007L43.4667 11.8775L39.1547 0.349487H34.3587L41.2887 17.5095H45.6447Z" />
<path d="M58.9678 17.5095H72.1458V13.2635H63.3678V11.0635H70.5838V7.08149H63.3678V4.61749H72.1458V0.349487H58.9678V17.5095Z" />
</svg>
<svg
width="75"
height="10"
viewBox="0 0 75 10"
xmlns="http://www.w3.org/2000/svg"
className="fill-[#7f818c]"
>
<path d="M4.84159 0.61946V1.86246H1.64059V3.83146H4.09359V5.05246H1.64059V8.29746H0.100586V0.61946H4.84159Z" />
<path d="M9.49759 8.37446C8.77893 8.37446 8.11893 8.20579 7.51759 7.86846C6.91626 7.53113 6.43959 7.06546 6.08759 6.47146C5.73559 5.87013 5.55959 5.19179 5.55959 4.43646C5.55959 3.68846 5.73559 3.01746 6.08759 2.42346C6.43959 1.82213 6.91626 1.35279 7.51759 1.01546C8.11893 0.678127 8.77893 0.50946 9.49759 0.50946C10.2236 0.50946 10.8836 0.678127 11.4776 1.01546C12.0789 1.35279 12.5519 1.82213 12.8966 2.42346C13.2486 3.01746 13.4246 3.68846 13.4246 4.43646C13.4246 5.19179 13.2486 5.87013 12.8966 6.47146C12.5519 7.06546 12.0789 7.53113 11.4776 7.86846C10.8763 8.20579 10.2163 8.37446 9.49759 8.37446ZM9.49759 6.99946C9.95959 6.99946 10.3666 6.89679 10.7186 6.69146C11.0706 6.47879 11.3456 6.17813 11.5436 5.78946C11.7416 5.40079 11.8406 4.94979 11.8406 4.43646C11.8406 3.92313 11.7416 3.47579 11.5436 3.09446C11.3456 2.70579 11.0706 2.40879 10.7186 2.20346C10.3666 1.99813 9.95959 1.89546 9.49759 1.89546C9.03559 1.89546 8.62493 1.99813 8.26559 2.20346C7.91359 2.40879 7.63859 2.70579 7.44059 3.09446C7.24259 3.47579 7.14359 3.92313 7.14359 4.43646C7.14359 4.94979 7.24259 5.40079 7.44059 5.78946C7.63859 6.17813 7.91359 6.47879 8.26559 6.69146C8.62493 6.89679 9.03559 6.99946 9.49759 6.99946Z" />
<path d="M18.5303 8.29746L16.8363 5.30546H16.1103V8.29746H14.5703V0.61946H17.4523C18.0463 0.61946 18.5523 0.725794 18.9703 0.93846C19.3883 1.14379 19.7 1.42613 19.9053 1.78546C20.118 2.13746 20.2243 2.53346 20.2243 2.97346C20.2243 3.47946 20.0776 3.93779 19.7843 4.34846C19.491 4.75179 19.0546 5.03046 18.4753 5.18446L20.3123 8.29746H18.5303ZM16.1103 4.15046H17.3973C17.8153 4.15046 18.127 4.05146 18.3323 3.85346C18.5376 3.64813 18.6403 3.36579 18.6403 3.00646C18.6403 2.65446 18.5376 2.38313 18.3323 2.19246C18.127 1.99446 17.8153 1.89546 17.3973 1.89546H16.1103V4.15046Z" />
<path d="M29.5843 2.92946C29.4083 2.60679 29.1663 2.36113 28.8583 2.19246C28.5503 2.02379 28.1909 1.93946 27.7803 1.93946C27.3256 1.93946 26.9223 2.04213 26.5703 2.24746C26.2183 2.45279 25.9433 2.74613 25.7453 3.12746C25.5473 3.50879 25.4483 3.94879 25.4483 4.44746C25.4483 4.96079 25.5473 5.40813 25.7453 5.78946C25.9506 6.17079 26.2329 6.46413 26.5923 6.66946C26.9516 6.87479 27.3696 6.97746 27.8463 6.97746C28.4329 6.97746 28.9133 6.82346 29.2873 6.51546C29.6613 6.20013 29.9069 5.76379 30.0243 5.20646H27.3843V4.02946H31.5423V5.37146C31.4396 5.90679 31.2196 6.40179 30.8823 6.85646C30.5449 7.31113 30.1086 7.67779 29.5733 7.95646C29.0453 8.22779 28.4513 8.36346 27.7913 8.36346C27.0506 8.36346 26.3796 8.19846 25.7783 7.86846C25.1843 7.53113 24.7149 7.06546 24.3703 6.47146C24.0329 5.87746 23.8643 5.20279 23.8643 4.44746C23.8643 3.69213 24.0329 3.01746 24.3703 2.42346C24.7149 1.82213 25.1843 1.35646 25.7783 1.02646C26.3796 0.689127 27.0469 0.52046 27.7803 0.52046C28.6456 0.52046 29.3973 0.733127 30.0353 1.15846C30.6733 1.57646 31.1133 2.16679 31.3553 2.92946H29.5843Z" />
<path d="M36.6416 8.29746L34.9476 5.30546H34.2216V8.29746H32.6816V0.61946H35.5636C36.1576 0.61946 36.6636 0.725794 37.0816 0.93846C37.4996 1.14379 37.8113 1.42613 38.0166 1.78546C38.2293 2.13746 38.3356 2.53346 38.3356 2.97346C38.3356 3.47946 38.189 3.93779 37.8956 4.34846C37.6023 4.75179 37.166 5.03046 36.5866 5.18446L38.4236 8.29746H36.6416ZM34.2216 4.15046H35.5086C35.9266 4.15046 36.2383 4.05146 36.4436 3.85346C36.649 3.64813 36.7516 3.36579 36.7516 3.00646C36.7516 2.65446 36.649 2.38313 36.4436 2.19246C36.2383 1.99446 35.9266 1.89546 35.5086 1.89546H34.2216V4.15046Z" />
<path d="M44.4365 6.83446H41.3785L40.8725 8.29746H39.2555L42.0165 0.60846H43.8095L46.5705 8.29746H44.9425L44.4365 6.83446ZM44.0185 5.60246L42.9075 2.39046L41.7965 5.60246H44.0185Z" />
<path d="M53.2015 2.99546C53.2015 3.40613 53.1025 3.79113 52.9045 4.15046C52.7139 4.50979 52.4095 4.79946 51.9915 5.01946C51.5809 5.23946 51.0602 5.34946 50.4295 5.34946H49.1425V8.29746H47.6025V0.61946H50.4295C51.0235 0.61946 51.5295 0.722127 51.9475 0.92746C52.3655 1.13279 52.6772 1.41513 52.8825 1.77446C53.0952 2.13379 53.2015 2.54079 53.2015 2.99546ZM50.3635 4.10646C50.7889 4.10646 51.1042 4.01113 51.3095 3.82046C51.5149 3.62246 51.6175 3.34746 51.6175 2.99546C51.6175 2.24746 51.1995 1.87346 50.3635 1.87346H49.1425V4.10646H50.3635Z" />
<path d="M60.6639 0.61946V8.29746H59.1239V5.03046H55.8349V8.29746H54.2949V0.61946H55.8349V3.77646H59.1239V0.61946H60.6639Z" />
<path d="M67.8997 9.65046L66.7337 8.25346C66.411 8.33413 66.081 8.37446 65.7437 8.37446C65.025 8.37446 64.365 8.20579 63.7637 7.86846C63.1624 7.53113 62.6857 7.06546 62.3337 6.47146C61.9817 5.87013 61.8057 5.19179 61.8057 4.43646C61.8057 3.68846 61.9817 3.01746 62.3337 2.42346C62.6857 1.82213 63.1624 1.35279 63.7637 1.01546C64.365 0.678127 65.025 0.50946 65.7437 0.50946C66.4697 0.50946 67.1297 0.678127 67.7237 1.01546C68.325 1.35279 68.798 1.82213 69.1427 2.42346C69.4947 3.01746 69.6707 3.68846 69.6707 4.43646C69.6707 5.11846 69.524 5.74179 69.2307 6.30646C68.9447 6.86379 68.5524 7.31846 68.0537 7.67046L69.8137 9.65046H67.8997ZM63.3897 4.43646C63.3897 4.94979 63.4887 5.40079 63.6867 5.78946C63.8847 6.17813 64.1597 6.47879 64.5117 6.69146C64.871 6.89679 65.2817 6.99946 65.7437 6.99946C66.2057 6.99946 66.6127 6.89679 66.9647 6.69146C67.3167 6.47879 67.5917 6.17813 67.7897 5.78946C67.9877 5.40079 68.0867 4.94979 68.0867 4.43646C68.0867 3.92313 67.9877 3.47579 67.7897 3.09446C67.5917 2.70579 67.3167 2.40879 66.9647 2.20346C66.6127 1.99813 66.2057 1.89546 65.7437 1.89546C65.2817 1.89546 64.871 1.99813 64.5117 2.20346C64.1597 2.40879 63.8847 2.70579 63.6867 3.09446C63.4887 3.47579 63.3897 3.92313 63.3897 4.43646Z" />
<path d="M72.3779 7.07646H74.9079V8.29746H70.8379V0.61946H72.3779V7.07646Z" />
</svg>
</div>
</NextLink>
);
};

View file

@ -452,42 +452,17 @@ export const HiveLogo = ({ className }: IconProps): ReactElement => (
<svg
width="42"
height="44"
viewBox="0 0 42 44"
viewBox="0 0 57 61"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={clsx('inline fill-none', className)}
>
<path
d="M1.66721 14.57C0.65238 13.9901 0 12.9028 0 11.6705C0 9.85831 1.52227 8.33609 3.33446 8.33609C3.84187 8.33609 4.27679 8.40859 4.63923 8.62605L18.6293 0.579937C19.2817 0.217498 20.0791 0 20.804 0C21.6013 0 22.3262 0.217498 22.9786 0.579937L34.8666 7.46627C33.9968 8.04617 33.3444 8.91595 32.9094 9.93078L21.3839 3.26195C21.1664 3.11697 20.949 3.11701 20.7315 3.11701C20.514 3.11701 20.2966 3.18946 20.0791 3.26195L6.45142 11.0907C6.45142 11.3081 6.52389 11.453 6.52389 11.6705C6.52389 13.1203 5.58155 14.2801 4.34926 14.7875C4.27677 14.7875 4.13182 14.86 4.05934 14.86H3.98682C3.91434 14.86 3.76939 14.9324 3.6969 14.9324H3.62438C3.47941 14.9324 3.40692 14.9324 3.26195 14.9324C3.11697 14.9324 2.97201 14.9324 2.82704 14.9324H2.75452C2.68204 14.9324 2.53709 14.9325 2.4646 14.86H2.39208C2.17462 14.7875 1.88467 14.715 1.66721 14.57ZM41.1005 11.6705C41.1005 12.6853 40.6656 13.5552 39.9407 14.1351V29.8649C39.9407 31.4596 39.0709 32.9094 37.7661 33.7068L25.8781 40.5206C25.8781 39.4333 25.5157 38.346 24.8633 37.5487L36.1714 31.0248C36.6063 30.8073 36.8237 30.3723 36.8237 29.8649V14.86C35.4465 14.4251 34.4317 13.1927 34.4317 11.6705C34.4317 10.9456 34.6491 10.2933 35.0841 9.71337C35.1565 9.64088 35.229 9.49589 35.3015 9.4234C35.519 9.20594 35.6639 9.061 35.8814 8.91602C35.8814 8.91602 35.9539 8.91595 35.9539 8.84346C36.0264 8.77098 36.0989 8.77101 36.2438 8.69852C36.2438 8.69852 36.3164 8.69854 36.3164 8.62605C36.4613 8.55357 36.5338 8.55351 36.6788 8.48103C36.9687 8.40854 37.3312 8.33609 37.6936 8.33609C39.5783 8.33609 41.1005 9.85831 41.1005 11.6705ZM24.1384 40.6656C24.1384 40.8106 24.1384 41.0281 24.0659 41.173V41.2455C23.776 42.7678 22.4712 44 20.804 44C19.3542 44 18.1219 43.0577 17.687 41.7529L3.91435 33.7793C2.53709 32.9819 1.73972 31.5322 1.73972 29.9375V16.5272C2.24714 16.6722 2.82705 16.8171 3.33446 16.8171C3.84187 16.8171 4.34929 16.7447 4.78421 16.5997V29.9375C4.78421 30.4449 5.07414 30.8798 5.43658 31.0972L18.3394 38.5634C18.9193 37.8385 19.8616 37.4036 20.8764 37.4036C21.9638 37.4036 22.9061 37.9111 23.5585 38.7809C23.5585 38.7809 23.5585 38.7809 23.5585 38.8534C23.631 38.9259 23.631 38.9984 23.7035 39.0709C23.7035 39.0709 23.7035 39.1434 23.776 39.1434C23.776 39.2158 23.8485 39.2883 23.8485 39.2883C23.8485 39.2883 23.8484 39.3609 23.9209 39.3609C23.9209 39.4333 23.9935 39.5058 23.9935 39.5058C23.9935 39.5783 23.9934 39.5782 24.0659 39.6507C24.0659 39.7232 24.1384 39.7233 24.1384 39.7958C24.1384 39.8683 24.1384 39.8682 24.2109 39.9407C24.2109 40.0132 24.2109 40.0132 24.2109 40.0857C24.2109 40.1582 24.2109 40.2307 24.2109 40.3031C24.2109 40.3756 24.2109 40.3757 24.2109 40.4482C24.1384 40.4482 24.1384 40.5206 24.1384 40.6656Z"
fill="url(#paint0_linear_2341_629)"
fillRule="evenodd"
clipRule="evenodd"
d="M0 16.1C0 17.7999 0.900024 19.2999 2.30005 20.1C2.45459 20.203 2.63562 20.2794 2.81592 20.343C2.9856 20.403 3.15454 20.4515 3.30005 20.5H3.40002C3.45203 20.552 3.53125 20.577 3.60925 20.589C3.68115 20.6 3.75208 20.6 3.80005 20.6H3.90002H4.5H5H5.1001C5.15015 20.6 5.2251 20.575 5.30005 20.55C5.375 20.525 5.44995 20.5 5.5 20.5H5.6001C5.65015 20.5 5.7251 20.475 5.80005 20.45C5.875 20.425 5.94995 20.4 6 20.4C7.69995 19.7001 9 18.1 9 16.1C9 15.8784 8.94543 15.7114 8.91699 15.5186C8.90686 15.4502 8.90002 15.3785 8.90002 15.3L27.7 4.5C28 4.40002 28.3 4.30005 28.6001 4.30005C28.9 4.30005 29.2 4.30005 29.5 4.5L45.4 13.7C46 12.2999 46.9 11.1001 48.1001 10.3L31.7 0.800049C30.8 0.300049 29.8 0 28.7 0C27.7 0 26.6 0.300049 25.7 0.800049L6.40002 11.9C5.90002 11.6 5.30005 11.5 4.6001 11.5C2.1001 11.5 0 13.6 0 16.1ZM55.1001 19.5C56.1001 18.7 56.7 17.5 56.7 16.1C56.7 13.6 54.6 11.5 52 11.5C51.5 11.5 51 11.6 50.6001 11.7C50.4 11.7999 50.3 11.8 50.1001 11.9C50.1001 12 50 12 50 12C49.9236 12.0382 49.8618 12.0618 49.8091 12.0819C49.7236 12.1146 49.6619 12.1382 49.6001 12.2C49.6001 12.2999 49.5 12.3 49.5 12.3C49.2 12.5 49 12.7 48.7 13C48.6499 13.05 48.6 13.125 48.55 13.2C48.5 13.275 48.45 13.3501 48.4 13.4C47.8 14.2001 47.5 15.1 47.5 16.1C47.5 18.2 48.9 19.9 50.8 20.5V41.2C50.8 41.8999 50.5 42.5 49.9 42.8L34.3 51.8C35.2 52.9 35.7 54.4 35.7 55.9L52.1001 46.5C53.9 45.4 55.1001 43.3999 55.1001 41.2V19.5ZM33.295 56.3519C33.3 56.261 33.3 56.1737 33.3 56.1C33.3 55.9 33.3 55.8 33.4 55.8V55.6V55.3V55.1C33.3425 55.0425 33.3181 55.0181 33.3077 54.9886C33.3 54.9669 33.3 54.9425 33.3 54.9C33.3 54.8 33.2 54.7999 33.2 54.7C33.1 54.6 33.1001 54.6 33.1001 54.5C33.1001 54.5 33.0583 54.4583 33.0288 54.4019C33.0126 54.3708 33 54.3354 33 54.3C32.9 54.3 32.9 54.2 32.9 54.2C32.9 54.2 32.8 54.1 32.8 54C32.7 54 32.7 53.9 32.7 53.9C32.6499 53.8501 32.625 53.8 32.6 53.75C32.575 53.7 32.55 53.6499 32.5 53.6V53.5C31.6 52.3 30.3 51.6 28.8 51.6C27.4 51.6 26.1 52.2 25.3 53.2L7.5 42.9C7 42.6 6.6001 42 6.6001 41.3V22.9C6 23.1 5.30005 23.2 4.6001 23.2C3.90002 23.2 3.09998 23 2.40002 22.8V41.3C2.40002 43.5 3.5 45.5 5.40002 46.6L24.4 57.6C25 59.4 26.7 60.7 28.7 60.7C31 60.7 32.8 59 33.2 56.9V56.8C33.2632 56.6738 33.2865 56.5076 33.295 56.3519ZM43.8319 24.9475L41.345 29.2535L43.8319 33.5596C44.056 33.9475 44.056 34.4257 43.8319 34.8137L40.9828 39.7468C40.7588 40.1349 40.3446 40.3739 39.8964 40.3739H34.9225L32.4355 44.6801C32.2114 45.068 31.7972 45.3071 31.349 45.3071H25.651C25.2028 45.3071 24.7886 45.068 24.5645 44.6801L22.0775 40.374H17.1036C16.6554 40.374 16.2412 40.135 16.0172 39.7469L13.1681 34.8137C12.944 34.4257 12.944 33.9476 13.1681 33.5596L15.655 29.2535L13.1681 24.9475C12.944 24.5594 12.944 24.0814 13.1681 23.6934L16.0172 18.7603C16.2412 18.3722 16.6554 18.1331 17.1036 18.1331H22.0775L24.5645 13.827C24.7886 13.439 25.2028 13.2 25.651 13.2H31.349C31.7972 13.2 32.2114 13.439 32.4355 13.827L34.9225 18.1331H39.8964C40.3446 18.1331 40.7588 18.3722 40.9828 18.7603L43.8319 23.6932C44.056 24.0813 44.056 24.5594 43.8319 24.9475ZM17.828 37.8656H22.0775L24.2023 34.1866L22.0775 30.5077H17.828L15.7032 34.1866L17.828 37.8656ZM17.828 27.9994H22.0775L24.2023 24.3203L22.0775 20.6414H17.828L15.7032 24.3203L17.828 27.9994ZM26.3754 42.7987H30.6248L32.7495 39.1199L30.6248 35.4409H26.3754L24.2505 39.1199L26.3754 42.7987ZM26.3752 25.5746L24.2505 29.2535L26.3752 32.9325H30.6248L32.7495 29.2535L30.6248 25.5746H26.3752ZM26.3754 23.0662H30.6248L32.7495 19.3872L30.6246 15.7084H26.3752L24.2505 19.3872L26.3754 23.0662ZM34.9225 37.8656H39.172L41.2968 34.1866L39.172 30.5077H34.9225L32.7977 34.1866L34.9225 37.8656ZM34.9225 27.9994H39.172L41.2968 24.3204L39.172 20.6415H34.9225L32.7977 24.3204L34.9225 27.9994Z"
fill="#eab308"
/>
<path
d="M29.97 21.2052L31.7727 18.0839C31.9351 17.8026 31.9351 17.456 31.7727 17.1747L29.7075 13.5989C29.545 13.3176 29.2448 13.1443 28.9199 13.1443H25.3145L23.5117 10.0229C23.3493 9.74164 23.0491 9.56836 22.7241 9.56836H18.5937C18.2688 9.56836 17.9686 9.74164 17.8061 10.0229L16.0034 13.1443H12.398C12.0731 13.1443 11.7729 13.3176 11.6104 13.5989L9.5452 17.1748C9.38272 17.4561 9.38272 17.8026 9.5452 18.0839L11.3479 21.2052L9.5452 24.3266C9.38272 24.6079 9.38272 24.9544 9.5452 25.2357L11.6104 28.8116C11.7729 29.0929 12.0731 29.2662 12.398 29.2662H16.0034L17.8061 32.3875C17.9686 32.6688 18.2688 32.8421 18.5937 32.8421H22.7241C23.0491 32.8421 23.3493 32.6688 23.5117 32.3875L25.3145 29.2662H28.9199C29.2448 29.2662 29.545 29.0929 29.7075 28.8116L31.7727 25.2357C31.9351 24.9544 31.9351 24.6078 31.7727 24.3265L29.97 21.2052ZM16.0034 27.4479H12.9231L11.3829 24.7811L12.9231 22.1144H16.0034L17.5436 24.7811C17.3566 25.105 16.1911 27.1229 16.0034 27.4479ZM16.0034 20.2961H12.9231L11.3829 17.6293L12.9231 14.9625H16.0034C16.1904 15.2863 17.3559 17.3043 17.5436 17.6293L16.0034 20.2961ZM22.1991 31.0238H19.1188L17.5786 28.3571C17.7656 28.0332 18.9311 26.0152 19.1188 25.6903H22.1991C22.3862 26.0141 23.5516 28.0321 23.7393 28.3571L22.1991 31.0238ZM17.5786 21.2052L19.1187 18.5384H22.1991L23.7393 21.2052L22.1991 23.872H19.1187L17.5786 21.2052ZM22.1991 16.7202H19.1188C18.9317 16.3963 17.7663 14.3783 17.5786 14.0534L19.1187 11.3866H22.1991L23.7393 14.0534C23.5523 14.3772 22.3868 16.3952 22.1991 16.7202ZM28.3948 27.4479H25.3145C25.1274 27.1241 23.962 25.1061 23.7742 24.7811L25.3145 22.1144H28.3948L29.935 24.7811L28.3948 27.4479ZM28.3948 20.2961H25.3145L23.7742 17.6293C23.9613 17.3055 25.1267 15.2875 25.3145 14.9625H28.3948L29.935 17.6293L28.3948 20.2961Z"
fill="url(#paint1_linear_2341_629)"
/>
<defs>
<linearGradient
id="paint0_linear_2341_629"
x1="20.5503"
y1={0}
x2="20.5503"
y2={44}
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#FF9900" />
<stop offset={1} stopColor="#F1F440" />
</linearGradient>
<linearGradient
id="paint1_linear_2341_629"
x1="20.6589"
y1="9.56836"
x2="20.6589"
y2="32.8421"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#FF9900" />
<stop offset={1} stopColor="#F1F440" />
</linearGradient>
</defs>
</svg>
);

View file

@ -13,7 +13,6 @@ export { DiffEditor } from '@/components/v2/diff-editor';
export { Drawer } from '@/components/v2/drawer';
export { EmptyList, noSchema } from '@/components/v2/empty-list';
export { GraphQLBlock } from '@/components/v2/graphql-block';
export { Header } from '@/components/v2/header';
export { Heading } from '@/components/v2/heading';
export { HiveLink } from '@/components/v2/hive-link';
export { Input } from '@/components/v2/input';
@ -30,13 +29,12 @@ export { Slider } from '@/components/v2/slider';
export { Sortable } from '@/components/v2/sortable';
export { Spinner } from '@/components/v2/spinner';
export { default as Stat } from '@/components/v2/stat';
export { SubHeader } from '@/components/v2/sub-header';
export { Switch } from '@/components/v2/switch';
export { Table, TBody, THead, TFoot, Th, Td, Tr } from '@/components/v2/table';
export { Tabs } from '@/components/v2/tabs';
export { Tag } from '@/components/v2/tag';
export { TimeAgo } from '@/components/v2/time-ago';
export { Title } from '@/components/v2/title';
export { MetaTitle as MetaTitle } from '@/components/v2/title';
export { ToggleGroup, ToggleGroupItem } from '@/components/v2/toggle-group';
export { Tooltip } from '@/components/v2/tooltip';
export { DocsNote, DocsLink } from '@/components/v2/docs-note';

View file

@ -391,7 +391,7 @@ function ModalContent(props: {
</div>
)}
</div>
<div className="container flex flex-col flex-1 overflow-hidden">
<div className="flex flex-col flex-1 overflow-hidden">
<Tabs
defaultValue="simple"
className="flex flex-col overflow-hidden"

View file

@ -4,7 +4,6 @@ import { useMutation, useQuery } from 'urql';
import * as Yup from 'yup';
import { Button, Heading, Input, Modal } from '@/components/v2';
import { graphql } from '@/gql';
import { TargetDocument } from '@/graphql';
import { useRouteSelector } from '@/lib/hooks';
const CollectionQuery = graphql(`
@ -116,15 +115,6 @@ export function CreateCollectionModal({
const [mutationCreate, mutateCreate] = useMutation(CreateCollectionMutation);
const [mutationUpdate, mutateUpdate] = useMutation(UpdateCollectionMutation);
const [result] = useQuery({
query: TargetDocument,
variables: {
targetId: router.targetId,
organizationId: router.organizationId,
projectId: router.projectId,
},
});
const [{ data, error: collectionError, fetching: loadingCollection }] = useQuery({
query: CollectionQuery,
variables: {
@ -138,8 +128,8 @@ export function CreateCollectionModal({
pause: !collectionId,
});
const error = mutationCreate.error || result.error || collectionError || mutationUpdate.error;
const fetching = loadingCollection || result.fetching;
const error = mutationCreate.error || collectionError || mutationUpdate.error;
const fetching = loadingCollection;
useEffect(() => {
if (!collectionId) {

View file

@ -8,14 +8,10 @@ import { graphql } from '@/gql';
import { ProjectType } from '@/graphql';
import { useRouteSelector } from '@/lib/hooks';
const CreateProjectMutation = graphql(`
export const CreateProjectMutation = graphql(`
mutation CreateProject_CreateProject($input: CreateProjectInput!) {
createProject(input: $input) {
ok {
selector {
organization
project
}
createdProject {
cleanId
...ProjectFields
@ -23,6 +19,9 @@ const CreateProjectMutation = graphql(`
createdTargets {
...TargetFields
}
updatedOrganization {
id
}
}
error {
message
@ -79,6 +78,8 @@ export const CreateProjectModal = ({
},
});
console.log(mutation);
return (
<Modal open={isOpen} onOpenChange={toggleModalOpen} className="w-[650px]">
<form onSubmit={handleSubmit} className="flex flex-col gap-8">

View file

@ -3,18 +3,22 @@ import { useRouter } from 'next/router';
import { useMutation } from 'urql';
import { Button, Heading, Modal } from '@/components/v2';
import { DeleteTargetDocument } from '@/graphql';
import { useRouteSelector } from '@/lib/hooks';
import { TrashIcon } from '@radix-ui/react-icons';
export const DeleteTargetModal = ({
isOpen,
toggleModalOpen,
organizationId,
projectId,
targetId,
}: {
isOpen: boolean;
toggleModalOpen: () => void;
organizationId: string;
projectId: string;
targetId: string;
}): ReactElement => {
const [, mutate] = useMutation(DeleteTargetDocument);
const router = useRouteSelector();
const { replace } = useRouter();
return (
@ -39,13 +43,13 @@ export const DeleteTargetModal = ({
onClick={async () => {
await mutate({
selector: {
organization: router.organizationId,
project: router.projectId,
target: router.targetId,
organization: organizationId,
project: projectId,
target: targetId,
},
});
toggleModalOpen();
void replace(`/${router.organizationId}/${router.projectId}`);
void replace(`/${organizationId}/${projectId}`);
}}
>
Delete

View file

@ -2,8 +2,6 @@ export { ChangePermissionsModal } from './change-permissions';
export { ConnectLabModal } from './connect-lab';
export { ConnectSchemaModal } from './connect-schema';
export { CreateAccessTokenModal } from './create-access-token';
export { CreateAlertModal } from './create-alert';
export { CreateChannelModal } from './create-channel';
export { CreateCollectionModal } from './create-collection';
export { CreateOperationModal } from './create-operation';
export { CreateProjectModal } from './create-project';

View file

@ -1,29 +0,0 @@
import { ReactElement, ReactNode } from 'react';
export const SubHeader = ({ children }: { children: ReactNode }): ReactElement => {
return (
<header
className={`
after:z-[-1]
relative
pt-20
after:absolute
after:inset-x-0
after:top-0
after:bottom-[-46px]
after:border-b
after:border-gray-800
after:content-['']
`}
>
<style jsx>{`
header::after {
background: url(/images/bg-top-shine.svg) no-repeat left top,
url(/images/bg-bottom-shine.svg) no-repeat right bottom;
}
`}</style>
{children}
</header>
);
};

View file

@ -17,8 +17,6 @@ const List = ({ children, className, ...props }: TabsListProps): ReactElement =>
relative
flex
items-center
gap-7
text-xl
text-gray-700
`,
className,
@ -35,19 +33,18 @@ const Trigger = forwardRef<any, Omit<TabsTriggerProps, 'className'> & { hasBorde
ref={forwardedRef}
className={clsx(
'!appearance-none', // unset button styles in Safari
`
radix-state-active:text-white
font-bold
transition
hover:text-white`,
hasBorder &&
'font-medium text-sm transition text-white',
hasBorder
? `
radix-state-active:border-b-orange-500
hover:border-b-orange-900
border-b-[2px]
border-b-transparent
px-4
py-3
cursor-pointer
`
radix-state-active:border-b-orange-500
border-b-[5px]
border-b-transparent
pb-3
cursor-pointer
`,
: null,
)}
{...props}
>

View file

@ -1,7 +1,7 @@
import { ReactElement } from 'react';
import Head from 'next/head';
export const Title = ({ title }: { title: string }): ReactElement => {
export const MetaTitle = ({ title }: { title: string }): ReactElement => {
const pageTitle = `${title} - GraphQL Hive`;
return (

View file

@ -50,24 +50,6 @@ fragment TargetEssentials on Target {
hasSchema
}
fragment SingleSchemaFields on SingleSchema {
__typename
id
author
source
commit
}
fragment CompositeSchemaFields on CompositeSchema {
__typename
id
author
source
service
url
commit
}
fragment MemberFields on Member {
id
user {
@ -376,34 +358,6 @@ fragment TargetValidationSettingsFields on TargetValidationSettings {
excludedClients
}
fragment AlertSlackChannelFields on AlertSlackChannel {
id
name
type
channel
}
fragment AlertWebhookChannelFields on AlertWebhookChannel {
id
name
type
endpoint
}
fragment AlertFields on Alert {
id
type
channel {
id
name
type
}
target {
id
cleanId
name
}
}
fragment BillingInvoiceFields on BillingInvoice {
id
amount

View file

@ -1,10 +0,0 @@
mutation addAlertChannel($input: AddAlertChannelInput!) {
addAlertChannel(input: $input) {
ok {
addedAlertChannel {
...AlertSlackChannelFields
...AlertWebhookChannelFields
}
}
}
}

View file

@ -1,5 +0,0 @@
mutation addAlert($input: AddAlertInput!) {
addAlert(input: $input) {
...AlertFields
}
}

View file

@ -1,16 +0,0 @@
mutation createProject($input: CreateProjectInput!) {
createProject(input: $input) {
ok {
selector {
organization
project
}
createdProject {
...ProjectFields
}
createdTargets {
...TargetFields
}
}
}
}

View file

@ -1,6 +0,0 @@
mutation deleteAlertChannels($input: DeleteAlertChannelsInput!) {
deleteAlertChannels(input: $input) {
__typename
id
}
}

View file

@ -1,6 +0,0 @@
mutation deleteAlerts($input: DeleteAlertsInput!) {
deleteAlerts(input: $input) {
__typename
id
}
}

View file

@ -1,6 +0,0 @@
query alertChannels($selector: ProjectSelectorInput!) {
alertChannels(selector: $selector) {
...AlertSlackChannelFields
...AlertWebhookChannelFields
}
}

View file

@ -1,5 +0,0 @@
query alerts($selector: ProjectSelectorInput!) {
alerts(selector: $selector) {
...AlertFields
}
}

View file

@ -1,6 +1,10 @@
query me {
me {
...UserFields
canSwitchOrganization
...MeFields
}
}
fragment MeFields on User {
...UserFields
canSwitchOrganization
}

Some files were not shown because too many files have changed in this diff Show more