mirror of
https://github.com/graphql-hive/console
synced 2026-04-21 14:37:17 +00:00
Face Lifting (#2418)
This commit is contained in:
parent
27294b1923
commit
a695ac297d
111 changed files with 6223 additions and 4009 deletions
2
.github/workflows/lint.yaml
vendored
2
.github/workflows/lint.yaml
vendored
|
|
@ -67,4 +67,4 @@ jobs:
|
|||
--maxDepth=20 \
|
||||
--maxAliasCount=20 \
|
||||
--maxDirectiveCount=20 \
|
||||
--maxTokenCount=800
|
||||
--maxTokenCount=850
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ const config = {
|
|||
scalars: {
|
||||
DateTime: 'string',
|
||||
SafeInt: 'number',
|
||||
ID: 'string',
|
||||
},
|
||||
mappers: {
|
||||
SchemaChangeConnection:
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
`;
|
||||
|
||||
|
|
|
|||
16
package.json
16
package.json
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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!
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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!]!
|
||||
}
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -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>>(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -48,9 +48,9 @@ export default gql`
|
|||
error: CreateProjectError
|
||||
}
|
||||
type CreateProjectOk {
|
||||
selector: ProjectSelector!
|
||||
createdProject: Project!
|
||||
createdTargets: [Target!]!
|
||||
updatedOrganization: Organization!
|
||||
}
|
||||
|
||||
type CreateProjectInputErrors {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
210
packages/web/app/pages/[orgId]/view/subscription.tsx
Normal file
210
packages/web/app/pages/[orgId]/view/subscription.tsx
Normal 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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
export { OrganizationLayout } from './organization';
|
||||
export { ProjectLayout } from './project';
|
||||
export { TargetLayout } from './target';
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
},
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
107
packages/web/app/src/components/target/operations/utils.ts
Normal file
107
packages/web/app/src/components/target/operations/utils.ts
Normal 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]);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
47
packages/web/app/src/components/ui/button.tsx
Normal file
47
packages/web/app/src/components/ui/button.tsx
Normal 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 };
|
||||
56
packages/web/app/src/components/ui/card.tsx
Normal file
56
packages/web/app/src/components/ui/card.tsx
Normal 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 };
|
||||
25
packages/web/app/src/components/ui/checkbox.tsx
Normal file
25
packages/web/app/src/components/ui/checkbox.tsx
Normal 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 };
|
||||
182
packages/web/app/src/components/ui/dropdown-menu.tsx
Normal file
182
packages/web/app/src/components/ui/dropdown-menu.tsx
Normal 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,
|
||||
};
|
||||
10
packages/web/app/src/components/ui/page.tsx
Normal file
10
packages/web/app/src/components/ui/page.tsx
Normal 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>;
|
||||
}
|
||||
136
packages/web/app/src/components/ui/select.tsx
Normal file
136
packages/web/app/src/components/ui/select.tsx
Normal 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,
|
||||
};
|
||||
27
packages/web/app/src/components/ui/tooltip.tsx
Normal file
27
packages/web/app/src/components/ui/tooltip.tsx
Normal 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 };
|
||||
202
packages/web/app/src/components/ui/user-menu.tsx
Normal file
202
packages/web/app/src/components/ui/user-menu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
||||
<TimeAgo date={activity.createdAt} className="float-right text-xs" />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,10 +0,0 @@
|
|||
mutation addAlertChannel($input: AddAlertChannelInput!) {
|
||||
addAlertChannel(input: $input) {
|
||||
ok {
|
||||
addedAlertChannel {
|
||||
...AlertSlackChannelFields
|
||||
...AlertWebhookChannelFields
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
mutation addAlert($input: AddAlertInput!) {
|
||||
addAlert(input: $input) {
|
||||
...AlertFields
|
||||
}
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
mutation createProject($input: CreateProjectInput!) {
|
||||
createProject(input: $input) {
|
||||
ok {
|
||||
selector {
|
||||
organization
|
||||
project
|
||||
}
|
||||
createdProject {
|
||||
...ProjectFields
|
||||
}
|
||||
createdTargets {
|
||||
...TargetFields
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
mutation deleteAlertChannels($input: DeleteAlertChannelsInput!) {
|
||||
deleteAlertChannels(input: $input) {
|
||||
__typename
|
||||
id
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
mutation deleteAlerts($input: DeleteAlertsInput!) {
|
||||
deleteAlerts(input: $input) {
|
||||
__typename
|
||||
id
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
query alertChannels($selector: ProjectSelectorInput!) {
|
||||
alertChannels(selector: $selector) {
|
||||
...AlertSlackChannelFields
|
||||
...AlertWebhookChannelFields
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
query alerts($selector: ProjectSelectorInput!) {
|
||||
alerts(selector: $selector) {
|
||||
...AlertFields
|
||||
}
|
||||
}
|
||||
|
|
@ -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
Loading…
Reference in a new issue