Compare commits

..

3 Commits

Author SHA1 Message Date
jamesgeorge007
4dc9030559 feat: access team workspace collections and environments from the CLI 2024-05-30 20:02:46 +05:30
Balu Babu
4bd23a8f4c feat: adding support for hopp-cli in self-host Hoppscotch (#4079)
* feat: created a new table to store user PATs

* chore: renamed UserTokens table name to PersonalAccessToken

* chore: added unique property to token column

* chore: made expiresOn field optional

* chore: created access-token module

* feat: created access-token rest routes

* chore: created a new auth guard for PATs

* chore: scaffolded routes in team collection and environments modules for CLI

* chore: created method to update lastUsedOn property for accessTokens

* chore: created interceptor to update lastUsedOn property of PAT

* feat: interceptor to update lastUpdatedOn property complete

* chore: removed unused imports in access-token interceptor

* chore: moved routes to fetch collection and environment into access-token module

* feat: added routes to fetch collections and environments for CLI

* chore: modified access-token interceptor

* chore: removed log statement from interceptor

* chore: added team member checking logic to ForCLI methods in team collection and environments module

* chore: changed return error messages to those defined in spec

* chore: added comments to all service methods

* chore: removed unused imports

* chore: updated testcases for team-environments module service file

* chore: added and updated testcases

* chore: removed unneseccary SQL from auto-generated migration sql for PAT

* chore: remobed JWTAuthGuard from relevant routes in PAT controllers file

* chore: modified token for auth in PATAuthGuard

* chore: changed error codes in some certain service methods in access-token module

* feat: worked on feedback for PR review

* chore: renamed service method in access-token module

* chore: removed console log statements

* chore: modified cli error type

* test: fix broken test case

* chore: changed target of hopp-old-backend to prod

---------

Co-authored-by: mirarifhasan <arif.ishan05@gmail.com>
2024-05-28 16:39:50 +05:30
Mir Arif Hasan
f4f3fdf2d5 HSB-445 feature: storing user last login timestamp (#4074)
* feat: lastLoggedOn added in schema and service function

* feat: add lastLoggedOn logic for magic link

* test: update test cases

* feat: add lastLoggedOn in gql model

* fix: nullable allowed in model attribute

* fix: resolve feedback

* feat: user last login interceptor added
2024-05-27 21:49:42 +05:30
95 changed files with 2943 additions and 12165 deletions

View File

@@ -118,11 +118,11 @@ services:
restart: always
environment:
# Edit the below line to match your PostgresDB URL if you have an outside DB (make sure to update the .env file as well)
- DATABASE_URL=postgresql://postgres:testpass@hoppscotch-db:5432/hoppscotch?connect_timeout=300
# - DATABASE_URL=postgresql://postgres:testpass@hoppscotch-db:5432/hoppscotch?connect_timeout=300
- PORT=3000
volumes:
# Uncomment the line below when modifying code. Only applicable when using the "dev" target.
# - ./packages/hoppscotch-backend/:/usr/src/app
- ./packages/hoppscotch-backend/:/usr/src/app
- /usr/src/app/node_modules/
depends_on:
hoppscotch-db:

View File

@@ -34,7 +34,7 @@
},
"pnpm": {
"overrides": {
"vue": "3.4.27"
"vue": "3.3.9"
},
"packageExtensions": {
"httpsnippet@3.0.1": {

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "lastLoggedOn" TIMESTAMP(3);

View File

@@ -0,0 +1,19 @@
-- CreateTable
CREATE TABLE "PersonalAccessToken" (
"id" TEXT NOT NULL,
"userUid" TEXT NOT NULL,
"label" TEXT NOT NULL,
"token" TEXT NOT NULL,
"expiresOn" TIMESTAMP(3),
"createdOn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedOn" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PersonalAccessToken_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "PersonalAccessToken_token_key" ON "PersonalAccessToken"("token");
-- AddForeignKey
ALTER TABLE "PersonalAccessToken" ADD CONSTRAINT "PersonalAccessToken_userUid_fkey" FOREIGN KEY ("userUid") REFERENCES "User"("uid") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -41,31 +41,31 @@ model TeamInvitation {
}
model TeamCollection {
id String @id @default(cuid())
id String @id @default(cuid())
parentID String?
data Json?
parent TeamCollection? @relation("TeamCollectionChildParent", fields: [parentID], references: [id])
children TeamCollection[] @relation("TeamCollectionChildParent")
parent TeamCollection? @relation("TeamCollectionChildParent", fields: [parentID], references: [id])
children TeamCollection[] @relation("TeamCollectionChildParent")
requests TeamRequest[]
teamID String
team Team @relation(fields: [teamID], references: [id], onDelete: Cascade)
team Team @relation(fields: [teamID], references: [id], onDelete: Cascade)
title String
orderIndex Int
createdOn DateTime @default(now()) @db.Timestamp(3)
updatedOn DateTime @updatedAt @db.Timestamp(3)
createdOn DateTime @default(now()) @db.Timestamp(3)
updatedOn DateTime @updatedAt @db.Timestamp(3)
}
model TeamRequest {
id String @id @default(cuid())
id String @id @default(cuid())
collectionID String
collection TeamCollection @relation(fields: [collectionID], references: [id], onDelete: Cascade)
collection TeamCollection @relation(fields: [collectionID], references: [id], onDelete: Cascade)
teamID String
team Team @relation(fields: [teamID], references: [id], onDelete: Cascade)
team Team @relation(fields: [teamID], references: [id], onDelete: Cascade)
title String
request Json
orderIndex Int
createdOn DateTime @default(now()) @db.Timestamp(3)
updatedOn DateTime @updatedAt @db.Timestamp(3)
createdOn DateTime @default(now()) @db.Timestamp(3)
updatedOn DateTime @updatedAt @db.Timestamp(3)
}
model Shortcode {
@@ -89,24 +89,26 @@ model TeamEnvironment {
}
model User {
uid String @id @default(cuid())
displayName String?
email String? @unique
photoURL String?
isAdmin Boolean @default(false)
refreshToken String?
providerAccounts Account[]
VerificationToken VerificationToken[]
settings UserSettings?
UserHistory UserHistory[]
UserEnvironments UserEnvironment[]
userCollections UserCollection[]
userRequests UserRequest[]
currentRESTSession Json?
currentGQLSession Json?
createdOn DateTime @default(now()) @db.Timestamp(3)
invitedUsers InvitedUsers[]
shortcodes Shortcode[]
uid String @id @default(cuid())
displayName String?
email String? @unique
photoURL String?
isAdmin Boolean @default(false)
refreshToken String?
providerAccounts Account[]
VerificationToken VerificationToken[]
settings UserSettings?
UserHistory UserHistory[]
UserEnvironments UserEnvironment[]
userCollections UserCollection[]
userRequests UserRequest[]
currentRESTSession Json?
currentGQLSession Json?
lastLoggedOn DateTime?
createdOn DateTime @default(now()) @db.Timestamp(3)
invitedUsers InvitedUsers[]
shortcodes Shortcode[]
personalAccessTokens PersonalAccessToken[]
}
model Account {
@@ -218,3 +220,14 @@ model InfraConfig {
createdOn DateTime @default(now()) @db.Timestamp(3)
updatedOn DateTime @updatedAt @db.Timestamp(3)
}
model PersonalAccessToken {
id String @id @default(cuid())
userUid String
user User @relation(fields: [userUid], references: [uid], onDelete: Cascade)
label String
token String @unique @default(uuid())
expiresOn DateTime? @db.Timestamp(3)
createdOn DateTime @default(now()) @db.Timestamp(3)
updatedOn DateTime @updatedAt @db.Timestamp(3)
}

View File

@@ -0,0 +1,107 @@
import {
BadRequestException,
Body,
Controller,
Delete,
Get,
HttpStatus,
Param,
ParseIntPipe,
Post,
Query,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { AccessTokenService } from './access-token.service';
import { CreateAccessTokenDto } from './dto/create-access-token.dto';
import { JwtAuthGuard } from 'src/auth/guards/jwt-auth.guard';
import * as E from 'fp-ts/Either';
import { throwHTTPErr } from 'src/utils';
import { GqlUser } from 'src/decorators/gql-user.decorator';
import { AuthUser } from 'src/types/AuthUser';
import { ThrottlerBehindProxyGuard } from 'src/guards/throttler-behind-proxy.guard';
import { PATAuthGuard } from 'src/guards/rest-pat-auth.guard';
import { AccessTokenInterceptor } from 'src/interceptors/access-token.interceptor';
import { TeamEnvironmentsService } from 'src/team-environments/team-environments.service';
import { TeamCollectionService } from 'src/team-collection/team-collection.service';
import { ACCESS_TOKENS_INVALID_DATA_ID } from 'src/errors';
import { createCLIErrorResponse } from './helper';
@UseGuards(ThrottlerBehindProxyGuard)
@Controller({ path: 'access-tokens', version: '1' })
export class AccessTokenController {
constructor(
private readonly accessTokenService: AccessTokenService,
private readonly teamCollectionService: TeamCollectionService,
private readonly teamEnvironmentsService: TeamEnvironmentsService,
) {}
@Post('create')
@UseGuards(JwtAuthGuard)
async createPAT(
@GqlUser() user: AuthUser,
@Body() createAccessTokenDto: CreateAccessTokenDto,
) {
const result = await this.accessTokenService.createPAT(
createAccessTokenDto,
user,
);
if (E.isLeft(result)) throwHTTPErr(result.left);
return result.right;
}
@Delete('revoke')
@UseGuards(JwtAuthGuard)
async deletePAT(@Query('id') id: string) {
const result = await this.accessTokenService.deletePAT(id);
if (E.isLeft(result)) throwHTTPErr(result.left);
return result.right;
}
@Get('list')
@UseGuards(JwtAuthGuard)
async listAllUserPAT(
@GqlUser() user: AuthUser,
@Query('offset', ParseIntPipe) offset: number,
@Query('limit', ParseIntPipe) limit: number,
) {
return await this.accessTokenService.listAllUserPAT(
user.uid,
offset,
limit,
);
}
@Get('collection/:id')
@UseGuards(PATAuthGuard)
@UseInterceptors(AccessTokenInterceptor)
async fetchCollection(@GqlUser() user: AuthUser, @Param('id') id: string) {
const res = await this.teamCollectionService.getCollectionForCLI(
id,
user.uid,
);
if (E.isLeft(res))
throw new BadRequestException(
createCLIErrorResponse(ACCESS_TOKENS_INVALID_DATA_ID),
);
return res.right;
}
@Get('environment/:id')
@UseGuards(PATAuthGuard)
@UseInterceptors(AccessTokenInterceptor)
async fetchEnvironment(@GqlUser() user: AuthUser, @Param('id') id: string) {
const res = await this.teamEnvironmentsService.getTeamEnvironmentForCLI(
id,
user.uid,
);
if (E.isLeft(res))
throw new BadRequestException(
createCLIErrorResponse(ACCESS_TOKENS_INVALID_DATA_ID),
);
return res.right;
}
}

View File

@@ -0,0 +1,20 @@
import { Module } from '@nestjs/common';
import { AccessTokenController } from './access-token.controller';
import { PrismaModule } from 'src/prisma/prisma.module';
import { AccessTokenService } from './access-token.service';
import { TeamCollectionModule } from 'src/team-collection/team-collection.module';
import { TeamEnvironmentsModule } from 'src/team-environments/team-environments.module';
import { TeamModule } from 'src/team/team.module';
@Module({
imports: [
PrismaModule,
TeamCollectionModule,
TeamEnvironmentsModule,
TeamModule,
],
controllers: [AccessTokenController],
providers: [AccessTokenService],
exports: [AccessTokenService],
})
export class AccessTokenModule {}

View File

@@ -0,0 +1,195 @@
import { AccessTokenService } from './access-token.service';
import { mockDeep, mockReset } from 'jest-mock-extended';
import { PrismaService } from 'src/prisma/prisma.service';
import {
ACCESS_TOKEN_EXPIRY_INVALID,
ACCESS_TOKEN_LABEL_SHORT,
ACCESS_TOKEN_NOT_FOUND,
} from 'src/errors';
import { AuthUser } from 'src/types/AuthUser';
import { PersonalAccessToken } from '@prisma/client';
import { AccessToken } from 'src/types/AccessToken';
import { HttpStatus } from '@nestjs/common';
const mockPrisma = mockDeep<PrismaService>();
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const accessTokenService = new AccessTokenService(mockPrisma);
const currentTime = new Date();
const user: AuthUser = {
uid: '123344',
email: 'dwight@dundermifflin.com',
displayName: 'Dwight Schrute',
photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute',
isAdmin: false,
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
createdOn: currentTime,
currentGQLSession: {},
currentRESTSession: {},
lastLoggedOn: currentTime,
};
const PATCreatedOn = new Date();
const expiryInDays = 7;
const PATExpiresOn = new Date(
PATCreatedOn.getTime() + expiryInDays * 24 * 60 * 60 * 1000,
);
const userAccessToken: PersonalAccessToken = {
id: 'skfvhj8uvdfivb',
userUid: user.uid,
label: 'test',
token: '0140e328-b187-4823-ae4b-ed4bec832ac2',
expiresOn: PATExpiresOn,
createdOn: PATCreatedOn,
updatedOn: new Date(),
};
const userAccessTokenCasted: AccessToken = {
id: userAccessToken.id,
label: userAccessToken.label,
createdOn: userAccessToken.createdOn,
lastUsedOn: userAccessToken.updatedOn,
expiresOn: userAccessToken.expiresOn,
};
beforeEach(() => {
mockReset(mockPrisma);
});
describe('AccessTokenService', () => {
describe('createPAT', () => {
test('should throw ACCESS_TOKEN_LABEL_SHORT if label is too short', async () => {
const result = await accessTokenService.createPAT(
{
label: 'a',
expiryInDays: 7,
},
user,
);
expect(result).toEqualLeft({
message: ACCESS_TOKEN_LABEL_SHORT,
statusCode: HttpStatus.BAD_REQUEST,
});
});
test('should throw ACCESS_TOKEN_EXPIRY_INVALID if expiry date is invalid', async () => {
const result = await accessTokenService.createPAT(
{
label: 'test',
expiryInDays: 9,
},
user,
);
expect(result).toEqualLeft({
message: ACCESS_TOKEN_EXPIRY_INVALID,
statusCode: HttpStatus.BAD_REQUEST,
});
});
test('should successfully create a new Access Token', async () => {
mockPrisma.personalAccessToken.create.mockResolvedValueOnce(
userAccessToken,
);
const result = await accessTokenService.createPAT(
{
label: userAccessToken.label,
expiryInDays,
},
user,
);
expect(result).toEqualRight({
token: `pat-${userAccessToken.token}`,
info: userAccessTokenCasted,
});
});
});
describe('deletePAT', () => {
test('should throw ACCESS_TOKEN_NOT_FOUND if Access Token is not found', async () => {
mockPrisma.personalAccessToken.delete.mockRejectedValueOnce(
'RecordNotFound',
);
const result = await accessTokenService.deletePAT(userAccessToken.id);
expect(result).toEqualLeft({
message: ACCESS_TOKEN_NOT_FOUND,
statusCode: HttpStatus.NOT_FOUND,
});
});
test('should successfully delete a new Access Token', async () => {
mockPrisma.personalAccessToken.delete.mockResolvedValueOnce(
userAccessToken,
);
const result = await accessTokenService.deletePAT(userAccessToken.id);
expect(result).toEqualRight(true);
});
});
describe('listAllUserPAT', () => {
test('should successfully return a list of user Access Tokens', async () => {
mockPrisma.personalAccessToken.findMany.mockResolvedValueOnce([
userAccessToken,
]);
const result = await accessTokenService.listAllUserPAT(user.uid, 0, 10);
expect(result).toEqual([userAccessTokenCasted]);
});
});
describe('getUserPAT', () => {
test('should throw ACCESS_TOKEN_NOT_FOUND if Access Token is not found', async () => {
mockPrisma.personalAccessToken.findUniqueOrThrow.mockRejectedValueOnce(
'NotFoundError',
);
const result = await accessTokenService.getUserPAT(userAccessToken.token);
expect(result).toEqualLeft(ACCESS_TOKEN_NOT_FOUND);
});
test('should successfully return a user Access Tokens', async () => {
mockPrisma.personalAccessToken.findUniqueOrThrow.mockResolvedValueOnce({
...userAccessToken,
user,
} as any);
const result = await accessTokenService.getUserPAT(
`pat-${userAccessToken.token}`,
);
expect(result).toEqualRight({
user,
...userAccessToken,
} as any);
});
});
describe('updateLastUsedforPAT', () => {
test('should throw ACCESS_TOKEN_NOT_FOUND if Access Token is not found', async () => {
mockPrisma.personalAccessToken.update.mockRejectedValueOnce(
'RecordNotFound',
);
const result = await accessTokenService.updateLastUsedForPAT(
userAccessToken.token,
);
expect(result).toEqualLeft(ACCESS_TOKEN_NOT_FOUND);
});
test('should successfully update lastUsedOn for a user Access Tokens', async () => {
mockPrisma.personalAccessToken.update.mockResolvedValueOnce(
userAccessToken,
);
const result = await accessTokenService.updateLastUsedForPAT(
`pat-${userAccessToken.token}`,
);
expect(result).toEqualRight(userAccessTokenCasted);
});
});
});

View File

@@ -0,0 +1,203 @@
import { HttpStatus, Injectable } from '@nestjs/common';
import { PrismaService } from 'src/prisma/prisma.service';
import { CreateAccessTokenDto } from './dto/create-access-token.dto';
import { AuthUser } from 'src/types/AuthUser';
import { isValidLength } from 'src/utils';
import * as E from 'fp-ts/Either';
import {
ACCESS_TOKEN_EXPIRY_INVALID,
ACCESS_TOKEN_LABEL_SHORT,
ACCESS_TOKEN_NOT_FOUND,
} from 'src/errors';
import { CreateAccessTokenResponse } from './helper';
import { PersonalAccessToken } from '@prisma/client';
import { AccessToken } from 'src/types/AccessToken';
@Injectable()
export class AccessTokenService {
constructor(private readonly prisma: PrismaService) {}
TITLE_LENGTH = 3;
VALID_TOKEN_DURATIONS = [7, 30, 60, 90];
TOKEN_PREFIX = 'pat-';
/**
* Calculate the expiration date of the token
*
* @param expiresOn Number of days the token is valid for
* @returns Date object of the expiration date
*/
private calculateExpirationDate(expiresOn: null | number) {
if (expiresOn === null) return null;
return new Date(Date.now() + expiresOn * 24 * 60 * 60 * 1000);
}
/**
* Validate the expiration date of the token
*
* @param expiresOn Number of days the token is valid for
* @returns Boolean indicating if the expiration date is valid
*/
private validateExpirationDate(expiresOn: null | number) {
if (expiresOn === null || this.VALID_TOKEN_DURATIONS.includes(expiresOn))
return true;
return false;
}
/**
* Typecast a database PersonalAccessToken to a AccessToken model
* @param token database PersonalAccessToken
* @returns AccessToken model
*/
private cast(token: PersonalAccessToken): AccessToken {
return <AccessToken>{
id: token.id,
label: token.label,
createdOn: token.createdOn,
expiresOn: token.expiresOn,
lastUsedOn: token.updatedOn,
};
}
/**
* Extract UUID from the token
*
* @param token Personal Access Token
* @returns UUID of the token
*/
private extractUUID(token): string | null {
if (!token.startsWith(this.TOKEN_PREFIX)) return null;
return token.slice(this.TOKEN_PREFIX.length);
}
/**
* Create a Personal Access Token
*
* @param createAccessTokenDto DTO for creating a Personal Access Token
* @param user AuthUser object
* @returns Either of the created token or error message
*/
async createPAT(createAccessTokenDto: CreateAccessTokenDto, user: AuthUser) {
const isTitleValid = isValidLength(
createAccessTokenDto.label,
this.TITLE_LENGTH,
);
if (!isTitleValid)
return E.left({
message: ACCESS_TOKEN_LABEL_SHORT,
statusCode: HttpStatus.BAD_REQUEST,
});
if (!this.validateExpirationDate(createAccessTokenDto.expiryInDays))
return E.left({
message: ACCESS_TOKEN_EXPIRY_INVALID,
statusCode: HttpStatus.BAD_REQUEST,
});
const createdPAT = await this.prisma.personalAccessToken.create({
data: {
userUid: user.uid,
label: createAccessTokenDto.label,
expiresOn: this.calculateExpirationDate(
createAccessTokenDto.expiryInDays,
),
},
});
const res: CreateAccessTokenResponse = {
token: `${this.TOKEN_PREFIX}${createdPAT.token}`,
info: this.cast(createdPAT),
};
return E.right(res);
}
/**
* Delete a Personal Access Token
*
* @param accessTokenID ID of the Personal Access Token
* @returns Either of true or error message
*/
async deletePAT(accessTokenID: string) {
try {
await this.prisma.personalAccessToken.delete({
where: { id: accessTokenID },
});
return E.right(true);
} catch {
return E.left({
message: ACCESS_TOKEN_NOT_FOUND,
statusCode: HttpStatus.NOT_FOUND,
});
}
}
/**
* List all Personal Access Tokens of a user
*
* @param userUid UID of the user
* @param offset Offset for pagination
* @param limit Limit for pagination
* @returns Either of the list of Personal Access Tokens or error message
*/
async listAllUserPAT(userUid: string, offset: number, limit: number) {
const userPATs = await this.prisma.personalAccessToken.findMany({
where: {
userUid: userUid,
},
skip: offset,
take: limit,
orderBy: {
createdOn: 'desc',
},
});
const userAccessTokenList = userPATs.map((pat) => this.cast(pat));
return userAccessTokenList;
}
/**
* Get a Personal Access Token
*
* @param accessToken Personal Access Token
* @returns Either of the Personal Access Token or error message
*/
async getUserPAT(accessToken: string) {
const extractedToken = this.extractUUID(accessToken);
if (!extractedToken) return E.left(ACCESS_TOKEN_NOT_FOUND);
try {
const userPAT = await this.prisma.personalAccessToken.findUniqueOrThrow({
where: { token: extractedToken },
include: { user: true },
});
return E.right(userPAT);
} catch {
return E.left(ACCESS_TOKEN_NOT_FOUND);
}
}
/**
* Update the last used date of a Personal Access Token
*
* @param token Personal Access Token
* @returns Either of the updated Personal Access Token or error message
*/
async updateLastUsedForPAT(token: string) {
const extractedToken = this.extractUUID(token);
if (!extractedToken) return E.left(ACCESS_TOKEN_NOT_FOUND);
try {
const updatedAccessToken = await this.prisma.personalAccessToken.update({
where: { token: extractedToken },
data: {
updatedOn: new Date(),
},
});
return E.right(this.cast(updatedAccessToken));
} catch {
return E.left(ACCESS_TOKEN_NOT_FOUND);
}
}
}

View File

@@ -0,0 +1,5 @@
// Inputs to create a new PAT
export class CreateAccessTokenDto {
label: string;
expiryInDays: number | null;
}

View File

@@ -0,0 +1,17 @@
import { AccessToken } from 'src/types/AccessToken';
// Response type of PAT creation method
export type CreateAccessTokenResponse = {
token: string;
info: AccessToken;
};
// Response type of any error in PAT module
export type CLIErrorResponse = {
reason: string;
};
// Return a CLIErrorResponse object
export function createCLIErrorResponse(reason: string): CLIErrorResponse {
return { reason };
}

View File

@@ -74,6 +74,7 @@ const dbAdminUsers: DbUser[] = [
refreshToken: 'refreshToken',
currentRESTSession: '',
currentGQLSession: '',
lastLoggedOn: new Date(),
createdOn: new Date(),
},
{
@@ -85,20 +86,10 @@ const dbAdminUsers: DbUser[] = [
refreshToken: 'refreshToken',
currentRESTSession: '',
currentGQLSession: '',
lastLoggedOn: new Date(),
createdOn: new Date(),
},
];
const dbNonAminUser: DbUser = {
uid: 'uid 3',
displayName: 'displayName',
email: 'email@email.com',
photoURL: 'photoURL',
isAdmin: false,
refreshToken: 'refreshToken',
currentRESTSession: '',
currentGQLSession: '',
createdOn: new Date(),
};
describe('AdminService', () => {
describe('fetchInvitedUsers', () => {

View File

@@ -27,6 +27,7 @@ import { MailerModule } from './mailer/mailer.module';
import { PosthogModule } from './posthog/posthog.module';
import { ScheduleModule } from '@nestjs/schedule';
import { HealthModule } from './health/health.module';
import { AccessTokenModule } from './access-token/access-token.module';
@Module({
imports: [
@@ -102,6 +103,7 @@ import { HealthModule } from './health/health.module';
PosthogModule,
ScheduleModule.forRoot(),
HealthModule,
AccessTokenModule,
],
providers: [GQLComplexityPlugin],
controllers: [AppController],

View File

@@ -7,6 +7,7 @@ import {
Request,
Res,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { AuthService } from './auth.service';
import { SignInMagicDto } from './dto/signin-magic.dto';
@@ -27,6 +28,7 @@ import { SkipThrottle } from '@nestjs/throttler';
import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors';
import { ConfigService } from '@nestjs/config';
import { throwHTTPErr } from 'src/utils';
import { UserLastLoginInterceptor } from 'src/interceptors/user-last-login.interceptor';
@UseGuards(ThrottlerBehindProxyGuard)
@Controller({ path: 'auth', version: '1' })
@@ -110,6 +112,7 @@ export class AuthController {
@Get('google/callback')
@SkipThrottle()
@UseGuards(GoogleSSOGuard)
@UseInterceptors(UserLastLoginInterceptor)
async googleAuthRedirect(@Request() req, @Res() res) {
const authTokens = await this.authService.generateAuthTokens(req.user.uid);
if (E.isLeft(authTokens)) throwHTTPErr(authTokens.left);
@@ -135,6 +138,7 @@ export class AuthController {
@Get('github/callback')
@SkipThrottle()
@UseGuards(GithubSSOGuard)
@UseInterceptors(UserLastLoginInterceptor)
async githubAuthRedirect(@Request() req, @Res() res) {
const authTokens = await this.authService.generateAuthTokens(req.user.uid);
if (E.isLeft(authTokens)) throwHTTPErr(authTokens.left);
@@ -160,6 +164,7 @@ export class AuthController {
@Get('microsoft/callback')
@SkipThrottle()
@UseGuards(MicrosoftSSOGuard)
@UseInterceptors(UserLastLoginInterceptor)
async microsoftAuthRedirect(@Request() req, @Res() res) {
const authTokens = await this.authService.generateAuthTokens(req.user.uid);
if (E.isLeft(authTokens)) throwHTTPErr(authTokens.left);

View File

@@ -51,6 +51,7 @@ const user: AuthUser = {
photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute',
isAdmin: false,
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
lastLoggedOn: currentTime,
createdOn: currentTime,
currentGQLSession: {},
currentRESTSession: {},
@@ -172,9 +173,11 @@ describe('verifyMagicLinkTokens', () => {
// generateAuthTokens
mockJWT.sign.mockReturnValue(user.refreshToken);
// UpdateUserRefreshToken
mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(E.right(user));
mockUser.updateUserRefreshToken.mockResolvedValueOnce(E.right(user));
// deletePasswordlessVerificationToken
mockPrisma.verificationToken.delete.mockResolvedValueOnce(passwordlessData);
// usersService.updateUserLastLoggedOn
mockUser.updateUserLastLoggedOn.mockResolvedValue(E.right(true));
const result = await authService.verifyMagicLinkTokens(magicLinkVerify);
expect(result).toEqualRight({
@@ -197,9 +200,11 @@ describe('verifyMagicLinkTokens', () => {
// generateAuthTokens
mockJWT.sign.mockReturnValue(user.refreshToken);
// UpdateUserRefreshToken
mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(E.right(user));
mockUser.updateUserRefreshToken.mockResolvedValueOnce(E.right(user));
// deletePasswordlessVerificationToken
mockPrisma.verificationToken.delete.mockResolvedValueOnce(passwordlessData);
// usersService.updateUserLastLoggedOn
mockUser.updateUserLastLoggedOn.mockResolvedValue(E.right(true));
const result = await authService.verifyMagicLinkTokens(magicLinkVerify);
expect(result).toEqualRight({
@@ -239,7 +244,7 @@ describe('verifyMagicLinkTokens', () => {
// generateAuthTokens
mockJWT.sign.mockReturnValue(user.refreshToken);
// UpdateUserRefreshToken
mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(
mockUser.updateUserRefreshToken.mockResolvedValueOnce(
E.left(USER_NOT_FOUND),
);
@@ -264,7 +269,7 @@ describe('verifyMagicLinkTokens', () => {
// generateAuthTokens
mockJWT.sign.mockReturnValue(user.refreshToken);
// UpdateUserRefreshToken
mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(E.right(user));
mockUser.updateUserRefreshToken.mockResolvedValueOnce(E.right(user));
// deletePasswordlessVerificationToken
mockPrisma.verificationToken.delete.mockRejectedValueOnce('RecordNotFound');
@@ -280,7 +285,7 @@ describe('generateAuthTokens', () => {
test('Should successfully generate tokens with valid inputs', async () => {
mockJWT.sign.mockReturnValue(user.refreshToken);
// UpdateUserRefreshToken
mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(E.right(user));
mockUser.updateUserRefreshToken.mockResolvedValueOnce(E.right(user));
const result = await authService.generateAuthTokens(user.uid);
expect(result).toEqualRight({
@@ -292,7 +297,7 @@ describe('generateAuthTokens', () => {
test('Should throw USER_NOT_FOUND when updating refresh tokens fails', async () => {
mockJWT.sign.mockReturnValue(user.refreshToken);
// UpdateUserRefreshToken
mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(
mockUser.updateUserRefreshToken.mockResolvedValueOnce(
E.left(USER_NOT_FOUND),
);
@@ -319,7 +324,7 @@ describe('refreshAuthTokens', () => {
// generateAuthTokens
mockJWT.sign.mockReturnValue(user.refreshToken);
// UpdateUserRefreshToken
mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(
mockUser.updateUserRefreshToken.mockResolvedValueOnce(
E.left(USER_NOT_FOUND),
);
@@ -348,7 +353,7 @@ describe('refreshAuthTokens', () => {
// generateAuthTokens
mockJWT.sign.mockReturnValue('sdhjcbjsdhcbshjdcb');
// UpdateUserRefreshToken
mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(
mockUser.updateUserRefreshToken.mockResolvedValueOnce(
E.right({
...user,
refreshToken: 'sdhjcbjsdhcbshjdcb',

View File

@@ -112,7 +112,7 @@ export class AuthService {
const refreshTokenHash = await argon2.hash(refreshToken);
const updatedUser = await this.usersService.UpdateUserRefreshToken(
const updatedUser = await this.usersService.updateUserRefreshToken(
refreshTokenHash,
userUid,
);
@@ -320,6 +320,8 @@ export class AuthService {
statusCode: HttpStatus.NOT_FOUND,
});
this.usersService.updateUserLastLoggedOn(passwordlessTokens.value.userUid);
return E.right(tokens.right);
}

View File

@@ -761,3 +761,39 @@ export const POSTHOG_CLIENT_NOT_INITIALIZED = 'posthog/client_not_initialized';
* Inputs supplied are invalid
*/
export const INVALID_PARAMS = 'invalid_parameters' as const;
/**
* The provided label for the access-token is short (less than 3 characters)
* (AccessTokenService)
*/
export const ACCESS_TOKEN_LABEL_SHORT = 'access_token/label_too_short';
/**
* The provided expiryInDays value is not valid
* (AccessTokenService)
*/
export const ACCESS_TOKEN_EXPIRY_INVALID = 'access_token/expiry_days_invalid';
/**
* The provided PAT ID is invalid
* (AccessTokenService)
*/
export const ACCESS_TOKEN_NOT_FOUND = 'access_token/access_token_not_found';
/**
* AccessTokens is expired
* (AccessTokenService)
*/
export const ACCESS_TOKENS_EXPIRED = 'TOKEN_EXPIRED';
/**
* AccessTokens is invalid
* (AccessTokenService)
*/
export const ACCESS_TOKENS_INVALID = 'TOKEN_INVALID';
/**
* AccessTokens is invalid
* (AccessTokenService)
*/
export const ACCESS_TOKENS_INVALID_DATA_ID = 'INVALID_ID';

View File

@@ -0,0 +1,48 @@
import {
BadRequestException,
CanActivate,
ExecutionContext,
Injectable,
} from '@nestjs/common';
import { Request } from 'express';
import { AccessTokenService } from 'src/access-token/access-token.service';
import * as E from 'fp-ts/Either';
import { DateTime } from 'luxon';
import { ACCESS_TOKENS_EXPIRED, ACCESS_TOKENS_INVALID } from 'src/errors';
import { createCLIErrorResponse } from 'src/access-token/helper';
@Injectable()
export class PATAuthGuard implements CanActivate {
constructor(private accessTokenService: AccessTokenService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const token = this.extractTokenFromHeader(request);
if (!token) {
throw new BadRequestException(
createCLIErrorResponse(ACCESS_TOKENS_INVALID),
);
}
const userAccessToken = await this.accessTokenService.getUserPAT(token);
if (E.isLeft(userAccessToken))
throw new BadRequestException(
createCLIErrorResponse(ACCESS_TOKENS_INVALID),
);
request.user = userAccessToken.right.user;
const accessToken = userAccessToken.right;
if (accessToken.expiresOn === null) return true;
const today = DateTime.now().toISO();
if (accessToken.expiresOn.toISOString() > today) return true;
throw new BadRequestException(
createCLIErrorResponse(ACCESS_TOKENS_EXPIRED),
);
}
private extractTokenFromHeader(request: Request): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
}

View File

@@ -0,0 +1,34 @@
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
UnauthorizedException,
} from '@nestjs/common';
import { Observable, map } from 'rxjs';
import { AccessTokenService } from 'src/access-token/access-token.service';
import * as E from 'fp-ts/Either';
@Injectable()
export class AccessTokenInterceptor implements NestInterceptor {
constructor(private readonly accessTokenService: AccessTokenService) {}
intercept(context: ExecutionContext, handler: CallHandler): Observable<any> {
const req = context.switchToHttp().getRequest();
const authHeader = req.headers.authorization;
const token = authHeader && authHeader.split(' ')[1];
if (!token) {
throw new UnauthorizedException();
}
return handler.handle().pipe(
map(async (data) => {
const userAccessToken =
await this.accessTokenService.updateLastUsedForPAT(token);
if (E.isLeft(userAccessToken)) throw new UnauthorizedException();
return data;
}),
);
}
}

View File

@@ -0,0 +1,26 @@
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
import { AuthUser } from 'src/types/AuthUser';
import { UserService } from 'src/user/user.service';
@Injectable()
export class UserLastLoginInterceptor implements NestInterceptor {
constructor(private userService: UserService) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const user: AuthUser = context.switchToHttp().getRequest().user;
const now = Date.now();
return next.handle().pipe(
tap(() => {
this.userService.updateUserLastLoggedOn(user.uid);
}),
);
}
}

View File

@@ -48,6 +48,7 @@ const user: AuthUser = {
photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute',
isAdmin: false,
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
lastLoggedOn: createdOn,
createdOn: createdOn,
currentGQLSession: {},
currentRESTSession: {},

View File

@@ -1,3 +1,5 @@
import { TeamRequest } from '@prisma/client';
// Type of data returned from the query to obtain all search results
export type SearchQueryReturnType = {
id: string;
@@ -12,3 +14,12 @@ export type ParentTreeQueryReturnType = {
parentID: string;
title: string;
};
// Type of data returned from the query to fetch collection details from CLI
export type GetCollectionResponse = {
id: string;
data: string | null;
title: string;
parentID: string | null;
folders: GetCollectionResponse[];
requests: TeamRequest[];
};

View File

@@ -12,6 +12,7 @@ import {
TEAM_COL_REORDERING_FAILED,
TEAM_COL_SAME_NEXT_COLL,
TEAM_INVALID_COLL_ID,
TEAM_MEMBER_NOT_FOUND,
TEAM_NOT_OWNER,
} from 'src/errors';
import { PrismaService } from 'src/prisma/prisma.service';
@@ -19,15 +20,18 @@ import { PubSubService } from 'src/pubsub/pubsub.service';
import { AuthUser } from 'src/types/AuthUser';
import { TeamCollectionService } from './team-collection.service';
import { TeamCollection } from './team-collection.model';
import { TeamService } from 'src/team/team.service';
const mockPrisma = mockDeep<PrismaService>();
const mockPubSub = mockDeep<PubSubService>();
const mockTeamService = mockDeep<TeamService>();
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const teamCollectionService = new TeamCollectionService(
mockPrisma,
mockPubSub as any,
mockTeamService,
);
const currentTime = new Date();
@@ -39,6 +43,7 @@ const user: AuthUser = {
photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute',
isAdmin: false,
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
lastLoggedOn: currentTime,
createdOn: currentTime,
currentGQLSession: {},
currentRESTSession: {},
@@ -1738,3 +1743,63 @@ describe('updateTeamCollection', () => {
});
//ToDo: write test cases for exportCollectionsToJSON
describe('getCollectionForCLI', () => {
test('should throw TEAM_COLL_NOT_FOUND if collectionID is invalid', async () => {
mockPrisma.teamCollection.findUniqueOrThrow.mockRejectedValueOnce(
'NotFoundError',
);
const result = await teamCollectionService.getCollectionForCLI(
'invalidID',
user.uid,
);
expect(result).toEqualLeft(TEAM_COLL_NOT_FOUND);
});
test('should throw TEAM_MEMBER_NOT_FOUND if user not in same team', async () => {
mockPrisma.teamCollection.findUniqueOrThrow.mockResolvedValueOnce(
rootTeamCollection,
);
mockTeamService.getTeamMember.mockResolvedValue(null);
const result = await teamCollectionService.getCollectionForCLI(
rootTeamCollection.id,
user.uid,
);
expect(result).toEqualLeft(TEAM_MEMBER_NOT_FOUND);
});
// test('should return the TeamCollection data for CLI', async () => {
// mockPrisma.teamCollection.findUniqueOrThrow.mockResolvedValueOnce(
// rootTeamCollection,
// );
// mockTeamService.getTeamMember.mockResolvedValue({
// membershipID: 'sdc3sfdv',
// userUid: user.uid,
// role: TeamMemberRole.OWNER,
// });
// const result = await teamCollectionService.getCollectionForCLI(
// rootTeamCollection.id,
// user.uid,
// );
// expect(result).toEqualRight({
// id: rootTeamCollection.id,
// data: JSON.stringify(rootTeamCollection.data),
// title: rootTeamCollection.title,
// parentID: rootTeamCollection.parentID,
// folders: [
// {
// id: childTeamCollection.id,
// data: JSON.stringify(childTeamCollection.data),
// title: childTeamCollection.title,
// parentID: childTeamCollection.parentID,
// folders: [],
// requests: [],
// },
// ],
// requests: [],
// });
// });
});

View File

@@ -18,23 +18,34 @@ import {
TEAM_COL_SEARCH_FAILED,
TEAM_REQ_PARENT_TREE_GEN_FAILED,
TEAM_COLL_PARENT_TREE_GEN_FAILED,
TEAM_MEMBER_NOT_FOUND,
} from '../errors';
import { PubSubService } from '../pubsub/pubsub.service';
import { escapeSqlLikeString, isValidLength } from 'src/utils';
import * as E from 'fp-ts/Either';
import * as O from 'fp-ts/Option';
import { Prisma, TeamCollection as DBTeamCollection } from '@prisma/client';
import {
Prisma,
TeamCollection as DBTeamCollection,
TeamRequest,
} from '@prisma/client';
import { CollectionFolder } from 'src/types/CollectionFolder';
import { stringToJson } from 'src/utils';
import { CollectionSearchNode } from 'src/types/CollectionSearchNode';
import { ParentTreeQueryReturnType, SearchQueryReturnType } from './helper';
import {
GetCollectionResponse,
ParentTreeQueryReturnType,
SearchQueryReturnType,
} from './helper';
import { RESTError } from 'src/types/RESTError';
import { TeamService } from 'src/team/team.service';
@Injectable()
export class TeamCollectionService {
constructor(
private readonly prisma: PrismaService,
private readonly pubsub: PubSubService,
private readonly teamService: TeamService,
) {}
TITLE_LENGTH = 3;
@@ -1344,4 +1355,95 @@ export class TeamCollectionService {
return E.left(TEAM_REQ_PARENT_TREE_GEN_FAILED);
}
}
/**
* Get all requests in a collection
*
* @param collectionID The Collection ID
* @returns A list of all requests in the collection
*/
private async getAllRequestsInCollection(collectionID: string) {
const dbTeamRequests = await this.prisma.teamRequest.findMany({
where: {
collectionID: collectionID,
},
orderBy: {
orderIndex: 'asc',
},
});
const teamRequests = dbTeamRequests.map((tr) => {
return <TeamRequest>{
id: tr.id,
collectionID: tr.collectionID,
teamID: tr.teamID,
title: tr.title,
request: JSON.stringify(tr.request),
};
});
return teamRequests;
}
/**
* Get Collection Tree for CLI
*
* @param parentID The parent Collection ID
* @returns Collection tree for CLI
*/
private async getCollectionTreeForCLI(parentID: string | null) {
const childCollections = await this.prisma.teamCollection.findMany({
where: { parentID },
orderBy: { orderIndex: 'asc' },
});
const response: GetCollectionResponse[] = [];
for (const collection of childCollections) {
const folder: GetCollectionResponse = {
id: collection.id,
data: collection.data === null ? null : JSON.stringify(collection.data),
title: collection.title,
parentID: collection.parentID,
folders: await this.getCollectionTreeForCLI(collection.id),
requests: await this.getAllRequestsInCollection(collection.id),
};
response.push(folder);
}
return response;
}
/**
* Get Collection for CLI
*
* @param collectionID The Collection ID
* @param userUid The User UID
* @returns An Either of the Collection details
*/
async getCollectionForCLI(collectionID: string, userUid: string) {
try {
const collection = await this.prisma.teamCollection.findUniqueOrThrow({
where: { id: collectionID },
});
const teamMember = await this.teamService.getTeamMember(
collection.teamID,
userUid,
);
if (!teamMember) return E.left(TEAM_MEMBER_NOT_FOUND);
return E.right(<GetCollectionResponse>{
id: collection.id,
data: collection.data === null ? null : JSON.stringify(collection.data),
title: collection.title,
parentID: collection.parentID,
folders: await this.getCollectionTreeForCLI(collection.id),
requests: await this.getAllRequestsInCollection(collection.id),
});
} catch (error) {
return E.left(TEAM_COLL_NOT_FOUND);
}
}
}

View File

@@ -6,19 +6,24 @@ import {
JSON_INVALID,
TEAM_ENVIRONMENT_NOT_FOUND,
TEAM_ENVIRONMENT_SHORT_NAME,
TEAM_MEMBER_NOT_FOUND,
} from 'src/errors';
import { TeamService } from 'src/team/team.service';
import { TeamMemberRole } from 'src/team/team.model';
const mockPrisma = mockDeep<PrismaService>();
const mockPubSub = {
publish: jest.fn().mockResolvedValue(null),
};
const mockTeamService = mockDeep<TeamService>();
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const teamEnvironmentsService = new TeamEnvironmentsService(
mockPrisma,
mockPubSub as any,
mockTeamService,
);
const teamEnvironment = {
@@ -380,4 +385,47 @@ describe('TeamEnvironmentsService', () => {
expect(result).toEqual(0);
});
});
describe('getTeamEnvironmentForCLI', () => {
test('should successfully return a TeamEnvironment with valid ID', async () => {
mockPrisma.teamEnvironment.findFirstOrThrow.mockResolvedValueOnce(
teamEnvironment,
);
mockTeamService.getTeamMember.mockResolvedValue({
membershipID: 'sdc3sfdv',
userUid: '123454',
role: TeamMemberRole.OWNER,
});
const result = await teamEnvironmentsService.getTeamEnvironmentForCLI(
teamEnvironment.id,
'123454',
);
expect(result).toEqualRight(teamEnvironment);
});
test('should throw TEAM_ENVIRONMENT_NOT_FOUND with invalid ID', async () => {
mockPrisma.teamEnvironment.findFirstOrThrow.mockRejectedValueOnce(
'RejectOnNotFound',
);
const result = await teamEnvironmentsService.getTeamEnvironment(
teamEnvironment.id,
);
expect(result).toEqualLeft(TEAM_ENVIRONMENT_NOT_FOUND);
});
test('should throw TEAM_MEMBER_NOT_FOUND if user not in same team', async () => {
mockPrisma.teamEnvironment.findFirstOrThrow.mockResolvedValueOnce(
teamEnvironment,
);
mockTeamService.getTeamMember.mockResolvedValue(null);
const result = await teamEnvironmentsService.getTeamEnvironmentForCLI(
teamEnvironment.id,
'333',
);
expect(result).toEqualLeft(TEAM_MEMBER_NOT_FOUND);
});
});
});

View File

@@ -6,14 +6,17 @@ import { TeamEnvironment } from './team-environments.model';
import {
TEAM_ENVIRONMENT_NOT_FOUND,
TEAM_ENVIRONMENT_SHORT_NAME,
TEAM_MEMBER_NOT_FOUND,
} from 'src/errors';
import * as E from 'fp-ts/Either';
import { isValidLength } from 'src/utils';
import { TeamService } from 'src/team/team.service';
@Injectable()
export class TeamEnvironmentsService {
constructor(
private readonly prisma: PrismaService,
private readonly pubsub: PubSubService,
private readonly teamService: TeamService,
) {}
TITLE_LENGTH = 3;
@@ -242,4 +245,30 @@ export class TeamEnvironmentsService {
});
return envCount;
}
/**
* Get details of a TeamEnvironment for CLI.
*
* @param id TeamEnvironment ID
* @param userUid User UID
* @returns Either of a TeamEnvironment or error message
*/
async getTeamEnvironmentForCLI(id: string, userUid: string) {
try {
const teamEnvironment =
await this.prisma.teamEnvironment.findFirstOrThrow({
where: { id },
});
const teamMember = await this.teamService.getTeamMember(
teamEnvironment.teamID,
userUid,
);
if (!teamMember) return E.left(TEAM_MEMBER_NOT_FOUND);
return E.right(teamEnvironment);
} catch (error) {
return E.left(TEAM_ENVIRONMENT_NOT_FOUND);
}
}
}

View File

@@ -0,0 +1,7 @@
export type AccessToken = {
id: string;
label: string;
createdOn: Date;
lastUsedOn: Date;
expiresOn: null | Date;
};

View File

@@ -5,6 +5,6 @@ import { HttpStatus } from '@nestjs/common';
** Since its REST we need to return the HTTP status code along with the error message
*/
export type RESTError = {
message: string;
message: string | Record<string, string>;
statusCode: HttpStatus;
};

View File

@@ -38,6 +38,7 @@ const user: AuthUser = {
photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute',
isAdmin: false,
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
lastLoggedOn: currentTime,
createdOn: currentTime,
currentGQLSession: {},
currentRESTSession: {},

View File

@@ -41,6 +41,7 @@ const user: AuthUser = {
photoURL: 'https://example.com/photo.png',
isAdmin: false,
refreshToken: null,
lastLoggedOn: new Date(),
createdOn: new Date(),
currentGQLSession: null,
currentRESTSession: null,

View File

@@ -27,6 +27,7 @@ const user: AuthUser = {
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
currentGQLSession: {},
currentRESTSession: {},
lastLoggedOn: currentTime,
createdOn: currentTime,
};

View File

@@ -30,6 +30,12 @@ export class User {
})
isAdmin: boolean;
@Field({
nullable: true,
description: 'Date when the user last logged in',
})
lastLoggedOn: Date;
@Field({
description: 'Date when the user account was created',
})

View File

@@ -42,6 +42,7 @@ const user: AuthUser = {
currentRESTSession: {},
currentGQLSession: {},
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
lastLoggedOn: currentTime,
createdOn: currentTime,
};
@@ -54,6 +55,7 @@ const adminUser: AuthUser = {
currentRESTSession: {},
currentGQLSession: {},
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
lastLoggedOn: currentTime,
createdOn: currentTime,
};
@@ -67,6 +69,7 @@ const users: AuthUser[] = [
currentRESTSession: {},
currentGQLSession: {},
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
lastLoggedOn: currentTime,
createdOn: currentTime,
},
{
@@ -78,6 +81,7 @@ const users: AuthUser[] = [
currentRESTSession: {},
currentGQLSession: {},
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
lastLoggedOn: currentTime,
createdOn: currentTime,
},
{
@@ -89,6 +93,7 @@ const users: AuthUser[] = [
currentRESTSession: {},
currentGQLSession: {},
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
lastLoggedOn: currentTime,
createdOn: currentTime,
},
];
@@ -103,6 +108,7 @@ const adminUsers: AuthUser[] = [
currentRESTSession: {},
currentGQLSession: {},
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
lastLoggedOn: currentTime,
createdOn: currentTime,
},
{
@@ -114,6 +120,7 @@ const adminUsers: AuthUser[] = [
currentRESTSession: {},
currentGQLSession: {},
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
lastLoggedOn: currentTime,
createdOn: currentTime,
},
{
@@ -125,6 +132,7 @@ const adminUsers: AuthUser[] = [
currentRESTSession: {},
currentGQLSession: {},
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
lastLoggedOn: currentTime,
createdOn: currentTime,
},
];
@@ -495,6 +503,26 @@ describe('UserService', () => {
});
});
describe('updateUserLastLoggedOn', () => {
test('should resolve right and update user last logged on', async () => {
const currentTime = new Date();
mockPrisma.user.update.mockResolvedValueOnce({
...user,
lastLoggedOn: currentTime,
});
const result = await userService.updateUserLastLoggedOn(user.uid);
expect(result).toEqualRight(true);
});
test('should resolve left and error when invalid user uid is passed', async () => {
mockPrisma.user.update.mockRejectedValueOnce('NotFoundError');
const result = await userService.updateUserLastLoggedOn('invalidUserUid');
expect(result).toEqualLeft(USER_NOT_FOUND);
});
});
describe('fetchAllUsers', () => {
test('should resolve right and return 20 users when cursor is null', async () => {
mockPrisma.user.findMany.mockResolvedValueOnce(users);

View File

@@ -114,7 +114,7 @@ export class UserService {
* @param userUid User uid
* @returns Either of User with updated refreshToken
*/
async UpdateUserRefreshToken(refreshTokenHash: string, userUid: string) {
async updateUserRefreshToken(refreshTokenHash: string, userUid: string) {
try {
const user = await this.prisma.user.update({
where: {
@@ -174,6 +174,7 @@ export class UserService {
displayName: userDisplayName,
email: profile.emails[0].value,
photoURL: userPhotoURL,
lastLoggedOn: new Date(),
providerAccounts: {
create: {
provider: profile.provider,
@@ -221,7 +222,7 @@ export class UserService {
}
/**
* Update User displayName and photoURL
* Update User displayName and photoURL when logged in via a SSO provider
*
* @param user User object
* @param profile Data received from SSO provider on the users account
@@ -236,6 +237,7 @@ export class UserService {
data: {
displayName: !profile.displayName ? null : profile.displayName,
photoURL: !profile.photos ? null : profile.photos[0].value,
lastLoggedOn: new Date(),
},
});
return E.right(updatedUser);
@@ -289,7 +291,7 @@ export class UserService {
}
/**
* Update a user's data
* Update a user's displayName
* @param userUID User UID
* @param displayName User's displayName
* @returns a Either of User or error
@@ -316,6 +318,22 @@ export class UserService {
}
}
/**
* Update user's lastLoggedOn timestamp
* @param userUID User UID
*/
async updateUserLastLoggedOn(userUid: string) {
try {
await this.prisma.user.update({
where: { uid: userUid },
data: { lastLoggedOn: new Date() },
});
return E.right(true);
} catch (e) {
return E.left(USER_NOT_FOUND);
}
}
/**
* Validate and parse currentRESTSession and currentGQLSession
* @param sessionData string of the session

View File

@@ -3,7 +3,7 @@ import { ExecException } from "child_process";
import { HoppErrorCode } from "../../types/errors";
import { runCLI, getErrorCode, getTestJsonFilePath } from "../utils";
describe("Test `hopp test <file>` command:", () => {
describe("Test `hopp test <file_path_or_id>` command:", () => {
describe("Argument parsing", () => {
test("Errors with the code `INVALID_ARGUMENT` for not supplying enough arguments", async () => {
const args = "test";
@@ -131,7 +131,7 @@ describe("Test `hopp test <file>` command:", () => {
});
});
describe("Test `hopp test <file> --env <file>` command:", () => {
describe("Test `hopp test <file_path_or_id> --env <file_path_or_id>` command:", () => {
describe("Supplied environment export file validations", () => {
const VALID_TEST_ARGS = `test ${getTestJsonFilePath("passes-coll.json", "collection")}`;
@@ -310,7 +310,7 @@ describe("Test `hopp test <file> --env <file>` command:", () => {
});
});
describe("Test `hopp test <file> --delay <delay_in_ms>` command:", () => {
describe("Test `hopp test <file_path_or_id> --delay <delay_in_ms>` command:", () => {
const VALID_TEST_ARGS = `test ${getTestJsonFilePath("passes-coll.json", "collection")}`;
test("Errors with the code `INVALID_ARGUMENT` on not supplying a delay value", async () => {
@@ -343,3 +343,116 @@ describe("Test `hopp test <file> --delay <delay_in_ms>` command:", () => {
expect(error).toBeNull();
});
});
describe.skip("Test `hopp test <file_path_or_id> --env <file_path_or_id> --token <access_token> --server <server_url>` command:", () => {
const {
REQ_BODY_ENV_VARS_COLL_ID,
COLLECTION_LEVEL_HEADERS_AUTH_COLL_ID,
REQ_BODY_ENV_VARS_ENVS_ID,
PERSONAL_ACCESS_TOKEN,
} = process.env;
if (
!REQ_BODY_ENV_VARS_COLL_ID ||
!COLLECTION_LEVEL_HEADERS_AUTH_COLL_ID ||
!REQ_BODY_ENV_VARS_ENVS_ID ||
!PERSONAL_ACCESS_TOKEN
) {
return;
}
const SERVER_URL = "https://stage-shc.hoppscotch.io/backend";
describe("Validations", () => {
test("Errors with the code `INVALID_ARGUMENT` on not supplying a value for the `--token` flag", async () => {
const args = `test ${REQ_BODY_ENV_VARS_COLL_ID} --token`;
const { stderr } = await runCLI(args);
const out = getErrorCode(stderr);
expect(out).toBe<HoppErrorCode>("INVALID_ARGUMENT");
});
test("Errors with the code `INVALID_ARGUMENT` on not supplying a value for the `--server` flag", async () => {
const args = `test ${REQ_BODY_ENV_VARS_COLL_ID} --server`;
const { stderr } = await runCLI(args);
const out = getErrorCode(stderr);
expect(out).toBe<HoppErrorCode>("INVALID_ARGUMENT");
});
test("Errors with the code `TOKEN_INVALID` if the supplied access token is invalid", async () => {
const args = `test ${REQ_BODY_ENV_VARS_COLL_ID} --token invalid-token --server ${SERVER_URL}`;
const { stderr } = await runCLI(args);
const out = getErrorCode(stderr);
expect(out).toBe<HoppErrorCode>("TOKEN_INVALID");
});
test("Errors with the code `TOKEN_INVALID` if the supplied collection ID is invalid", async () => {
const args = `test invalid-id --token ${PERSONAL_ACCESS_TOKEN} --server ${SERVER_URL}`;
const { stderr } = await runCLI(args);
const out = getErrorCode(stderr);
expect(out).toBe<HoppErrorCode>("INVALID_ID");
});
test("Errors with the code `TOKEN_INVALID` if the supplied environment ID is invalid", async () => {
const args = `test ${REQ_BODY_ENV_VARS_COLL_ID} --env invalid-id --token ${PERSONAL_ACCESS_TOKEN} --server ${SERVER_URL}`;
const { stderr } = await runCLI(args);
const out = getErrorCode(stderr);
expect(out).toBe<HoppErrorCode>("INVALID_ID");
});
});
test("Successfully retrieves a collection with the ID", async () => {
const args = `test ${COLLECTION_LEVEL_HEADERS_AUTH_COLL_ID} --token ${PERSONAL_ACCESS_TOKEN} --server ${SERVER_URL}`;
const { error } = await runCLI(args);
expect(error).toBeNull();
});
test("Successfully retrieves collections and environments from a workspace using their respective IDs", async () => {
const args = `test ${REQ_BODY_ENV_VARS_COLL_ID} --env ${REQ_BODY_ENV_VARS_ENVS_ID} --token ${PERSONAL_ACCESS_TOKEN} --server ${SERVER_URL}`;
const { error } = await runCLI(args);
expect(error).toBeNull();
});
test("Supports specifying collection file path along with environment ID", async () => {
const TESTS_PATH = getTestJsonFilePath(
"req-body-env-vars-coll.json",
"collection"
);
const args = `test ${TESTS_PATH} --env ${REQ_BODY_ENV_VARS_ENVS_ID} --token ${PERSONAL_ACCESS_TOKEN} --server ${SERVER_URL}`;
const { error } = await runCLI(args);
expect(error).toBeNull();
});
test("Supports specifying environment file path along with collection ID", async () => {
const ENV_PATH = getTestJsonFilePath(
"req-body-env-vars-envs.json",
"environment"
);
const args = `test ${REQ_BODY_ENV_VARS_COLL_ID} --env ${ENV_PATH} --token ${PERSONAL_ACCESS_TOKEN} --server ${SERVER_URL}`;
const { error } = await runCLI(args);
expect(error).toBeNull();
});
test("Supports specifying both collection and environment file paths", async () => {
const TESTS_PATH = getTestJsonFilePath(
"req-body-env-vars-coll.json",
"collection"
);
const ENV_PATH = getTestJsonFilePath(
"req-body-env-vars-envs.json",
"environment"
);
const args = `test ${TESTS_PATH} --env ${ENV_PATH} --token ${PERSONAL_ACCESS_TOKEN}`;
const { error } = await runCLI(args);
expect(error).toBeNull();
});
});

View File

@@ -1,30 +1,38 @@
import { handleError } from "../handlers/error";
import { parseDelayOption } from "../options/test/delay";
import { parseEnvsData } from "../options/test/env";
import { TestCmdOptions } from "../types/commands";
import { HoppEnvs } from "../types/request";
import { isHoppCLIError } from "../utils/checks";
import {
collectionsRunner,
collectionsRunnerExit,
collectionsRunnerResult,
} from "../utils/collections";
import { handleError } from "../handlers/error";
import { parseCollectionData } from "../utils/mutators";
import { parseEnvsData } from "../options/test/env";
import { TestCmdOptions } from "../types/commands";
import { parseDelayOption } from "../options/test/delay";
import { HoppEnvs } from "../types/request";
import { isHoppCLIError } from "../utils/checks";
export const test = (path: string, options: TestCmdOptions) => async () => {
export const test = (pathOrId: string, options: TestCmdOptions) => async () => {
try {
const delay = options.delay ? parseDelayOption(options.delay) : 0
const envs = options.env ? await parseEnvsData(options.env) : <HoppEnvs>{ global: [], selected: [] }
const collections = await parseCollectionData(path)
const delay = options.delay ? parseDelayOption(options.delay) : 0;
const report = await collectionsRunner({collections, envs, delay})
const hasSucceeded = collectionsRunnerResult(report)
collectionsRunnerExit(hasSucceeded)
} catch(e) {
if(isHoppCLIError(e)) {
handleError(e)
const envs = options.env
? await parseEnvsData(options.env, options.token, options.server)
: <HoppEnvs>{ global: [], selected: [] };
const collections = await parseCollectionData(
pathOrId,
options.token,
options.server
);
const report = await collectionsRunner({ collections, envs, delay });
const hasSucceeded = collectionsRunnerResult(report);
collectionsRunnerExit(hasSucceeded);
} catch (e) {
if (isHoppCLIError(e)) {
handleError(e);
process.exit(1);
}
else throw e
} else throw e;
}
};

View File

@@ -82,6 +82,15 @@ export const handleError = <T extends HoppErrorCode>(error: HoppError<T>) => {
case "TESTS_FAILING":
ERROR_MSG = error.data;
break;
case "TOKEN_EXPIRED":
ERROR_MSG = `Token is expired: ${error.data}`;
break;
case "TOKEN_INVALID":
ERROR_MSG = `Token is invalid/removed: ${error.data}`;
break;
case "INVALID_ID":
ERROR_MSG = `The collection/environment is not valid/not accessible to the user: ${error.data}`;
break;
}
if (!S.isEmpty(ERROR_MSG)) {

View File

@@ -49,14 +49,22 @@ program.exitOverride().configureOutput({
program
.command("test")
.argument(
"<file_path>",
"path to a hoppscotch collection.json file for CI testing"
"<file_path_or_id>",
"path to a hoppscotch collection.json file or collection ID from a workspace for CI testing"
)
.option(
"-e, --env <file_path_or_id>",
"path to an environment variables json file or environment ID from a workspace"
)
.option("-e, --env <file_path>", "path to an environment variables json file")
.option(
"-d, --delay <delay_in_ms>",
"delay in milliseconds(ms) between consecutive requests within a collection"
)
.option(
"--token <access_token>",
"personal access token to access collections/environments from a workspace"
)
.option("--server <server_url>", "server URL for SH instance")
.allowExcessArguments(false)
.allowUnknownOption(false)
.description("running hoppscotch collection.json file")
@@ -66,7 +74,7 @@ program
"https://docs.hoppscotch.io/documentation/clients/cli#commands"
)}`
)
.action(async (path, options) => await test(path, options)());
.action(async (pathOrId, options) => await test(pathOrId, options)());
export const cli = async (args: string[]) => {
try {

View File

@@ -1,4 +1,6 @@
import { Environment } from "@hoppscotch/data";
import { Environment, EnvironmentSchemaVersion } from "@hoppscotch/data";
import axios, { AxiosError } from "axios";
import fs from "fs/promises";
import { entityReference } from "verzod";
import { z } from "zod";
@@ -10,13 +12,94 @@ import {
} from "../../types/request";
import { readJsonFile } from "../../utils/mutators";
interface WorkspaceEnvironment {
id: string;
teamID: string;
name: string;
variables: HoppEnvPair[];
}
// Helper functions to transform workspace environment data to the `HoppEnvironment` format
const transformWorkspaceEnvironment = (
workspaceEnvironment: WorkspaceEnvironment
): Environment => {
const { teamID, variables, ...rest } = workspaceEnvironment;
// Add `secret` field if the data conforms to an older schema
const transformedEnvVars = variables.map((variable) => {
if (!("secret" in variable)) {
return {
...(variable as HoppEnvPair),
secret: false,
} as HoppEnvPair;
}
return variable;
});
return {
v: EnvironmentSchemaVersion,
variables: transformedEnvVars,
...rest,
};
};
/**
* Parses env json file for given path and validates the parsed env json object
* @param path Path of env.json file to be parsed
* @param pathOrId Path of env.json file to be parsed
* @param [accessToken] Personal access token to fetch workspace environments
* @param [serverUrl] server URL for SH instance
* @returns For successful parsing we get HoppEnvs object
*/
export async function parseEnvsData(path: string) {
const contents = await readJsonFile(path);
export async function parseEnvsData(
pathOrId: string,
accessToken?: string,
serverUrl?: string
) {
let contents = null;
let fileExistsInPath = null;
try {
await fs.access(pathOrId);
fileExistsInPath = true;
} catch (e) {
fileExistsInPath = false;
}
if (accessToken && !fileExistsInPath) {
try {
const hostname = serverUrl || "https://api.hoppscotch.io";
const url = `${hostname.endsWith("/") ? hostname.slice(0, -1) : hostname}/v1/access-tokens/environment/${pathOrId}`;
const { data }: { data: WorkspaceEnvironment } = await axios.get(url, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
contents = transformWorkspaceEnvironment(data);
} catch (err) {
const errReason = (
err as AxiosError<{
reason?: any;
message: string;
error: string;
statusCode: number;
}>
).response?.data?.reason;
if (errReason) {
throw error({
code: errReason,
data: pathOrId,
});
}
}
}
if (contents === null) {
contents = await readJsonFile(pathOrId);
}
const envPairs: Array<HoppEnvPair | Record<string, string>> = [];
// The legacy key-value pair format that is still supported
@@ -33,7 +116,7 @@ export async function parseEnvsData(path: string) {
// CLI doesnt support bulk environments export
// Hence we check for this case and throw an error if it matches the format
if (HoppBulkEnvExportObjectResult.success) {
throw error({ code: "BULK_ENV_FILE", path, data: error });
throw error({ code: "BULK_ENV_FILE", path: pathOrId, data: error });
}
// Checks if the environment file is of the correct format
@@ -42,7 +125,7 @@ export async function parseEnvsData(path: string) {
!HoppEnvKeyPairResult.success &&
HoppEnvExportObjectResult.type === "err"
) {
throw error({ code: "MALFORMED_ENV_FILE", path, data: error });
throw error({ code: "MALFORMED_ENV_FILE", path: pathOrId, data: error });
}
if (HoppEnvKeyPairResult.success) {

View File

@@ -1,6 +1,8 @@
export type TestCmdOptions = {
env: string | undefined;
delay: string | undefined;
token: string | undefined;
server: string | undefined;
};
export type HOPP_ENV_FILE_EXT = "json";

View File

@@ -26,6 +26,9 @@ type HoppErrors = {
MALFORMED_ENV_FILE: HoppErrorPath & HoppErrorData;
BULK_ENV_FILE: HoppErrorPath & HoppErrorData;
INVALID_FILE_TYPE: HoppErrorData;
TOKEN_EXPIRED: HoppErrorData;
TOKEN_INVALID: HoppErrorData;
INVALID_ID: HoppErrorData;
};
export type HoppErrorCode = keyof HoppErrors;

View File

@@ -1,12 +1,34 @@
import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data";
import {
CollectionSchemaVersion,
HoppCollection,
HoppRESTRequest,
} from "@hoppscotch/data";
import fs from "fs/promises";
import { entityReference } from "verzod";
import { z } from "zod";
import axios, { AxiosError } from "axios";
import { error } from "../types/errors";
import { FormDataEntry } from "../types/request";
import { isHoppErrnoException } from "./checks";
interface WorkspaceCollection {
id: string;
data: string | null;
title: string;
parentID: string | null;
folders: WorkspaceCollection[];
requests: WorkspaceRequest[];
}
interface WorkspaceRequest {
id: string;
collectionID: string;
teamID: string;
title: string;
request: string;
}
const getValidRequests = (
collections: HoppCollection[],
collectionFilePath: string
@@ -42,6 +64,50 @@ const getValidRequests = (
});
};
// Helper functions to transform workspace collection data to the `HoppCollection` format
const transformWorkspaceRequests = (requests: WorkspaceRequest[]) =>
requests.map(({ request }) => JSON.parse(request));
const transformChildCollections = (
childCollections: WorkspaceCollection[]
): HoppCollection[] => {
return childCollections.map(({ id, title, data, folders, requests }) => {
const parsedData = data ? JSON.parse(data) : {};
const { auth = { authType: "inherit", authActive: false }, headers = [] } =
parsedData;
return {
v: CollectionSchemaVersion,
id,
name: title,
folders: transformChildCollections(folders),
requests: transformWorkspaceRequests(requests),
auth,
headers,
};
});
};
const transformWorkspaceCollection = (
collection: WorkspaceCollection
): HoppCollection => {
const { id, title, data, requests, folders } = collection;
const parsedData = data ? JSON.parse(data) : {};
const { auth = { authType: "inherit", authActive: false }, headers = [] } =
parsedData;
return {
v: CollectionSchemaVersion,
id,
name: title,
folders: transformChildCollections(folders),
requests: transformWorkspaceRequests(requests),
auth,
headers,
};
};
/**
* Parses array of FormDataEntry to FormData.
* @param values Array of FormDataEntry.
@@ -92,16 +158,64 @@ export async function readJsonFile(path: string): Promise<unknown> {
/**
* Parses collection json file for given path:context.path, and validates
* the parsed collectiona array.
* @param path Collection json file path.
* @returns For successful parsing we get array of HoppCollection,
* the parsed collectiona array
* @param pathOrId Collection json file path
* @param [accessToken] Personal access token to fetch workspace environments
* @param [serverUrl] server URL for SH instance
* @returns For successful parsing we get array of HoppCollection
*/
export async function parseCollectionData(
path: string
pathOrId: string,
accessToken?: string,
serverUrl?: string
): Promise<HoppCollection[]> {
let contents = await readJsonFile(path);
let contents = null;
let fileExistsInPath = null;
const maybeArrayOfCollections: unknown[] = Array.isArray(contents)
try {
await fs.access(pathOrId);
fileExistsInPath = true;
} catch (e) {
fileExistsInPath = false;
}
if (accessToken && !fileExistsInPath) {
try {
const hostname = serverUrl || "https://api.hoppscotch.io";
const url = `${hostname.endsWith("/") ? hostname.slice(0, -1) : hostname}/v1/access-tokens/collection/${pathOrId}`;
const { data }: { data: WorkspaceCollection } = await axios.get(url, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
contents = transformWorkspaceCollection(data);
} catch (err) {
const errReason = (
err as AxiosError<{
reason?: any;
message: string;
error: string;
statusCode: number;
}>
).response?.data?.reason;
if (errReason) {
throw error({
code: errReason,
data: pathOrId,
});
}
}
}
// Fallback to reading from file if contents are not available
if (contents === null) {
contents = await readJsonFile(pathOrId);
}
const maybeArrayOfCollections: HoppCollection[] = Array.isArray(contents)
? contents
: [contents];
@@ -110,12 +224,14 @@ export async function parseCollectionData(
.safeParse(maybeArrayOfCollections);
if (!collectionSchemaParsedResult.success) {
console.error(`Error is `, collectionSchemaParsedResult.error);
throw error({
code: "MALFORMED_COLLECTION",
path,
path: pathOrId,
data: "Please check the collection data.",
});
}
return getValidRequests(collectionSchemaParsedResult.data, path);
return getValidRequests(collectionSchemaParsedResult.data, pathOrId);
}

View File

@@ -1027,8 +1027,7 @@
"personal": "Personal Workspace",
"other_workspaces": "My Workspaces",
"team": "Workspace",
"title": "Workspaces",
"no_workspace": "No Workspace"
"title": "Workspaces"
},
"site_protection": {
"login_to_continue": "Login to continue",

View File

@@ -90,7 +90,7 @@
"util": "0.12.5",
"uuid": "9.0.1",
"verzod": "0.2.2",
"vue": "3.4.27",
"vue": "3.3.9",
"vue-i18n": "9.8.0",
"vue-pdf-embed": "1.2.1",
"vue-router": "4.2.5",

View File

@@ -181,10 +181,6 @@ declare module 'vue' {
LensesRenderersVideoLensRenderer: typeof import('./components/lenses/renderers/VideoLensRenderer.vue')['default']
LensesRenderersXMLLensRenderer: typeof import('./components/lenses/renderers/XMLLensRenderer.vue')['default']
LensesResponseBodyRenderer: typeof import('./components/lenses/ResponseBodyRenderer.vue')['default']
NewCollections: typeof import('./components/new-collections/index.vue')['default']
NewCollectionsRest: typeof import('./components/new-collections/rest/index.vue')['default']
NewCollectionsRestCollection: typeof import('./components/new-collections/rest/Collection.vue')['default']
NewCollectionsRestRequest: typeof import('./components/new-collections/rest/Request.vue')['default']
ProfileUserDelete: typeof import('./components/profile/UserDelete.vue')['default']
RealtimeCommunication: typeof import('./components/realtime/Communication.vue')['default']
RealtimeConnectionConfig: typeof import('./components/realtime/ConnectionConfig.vue')['default']
@@ -216,8 +212,6 @@ declare module 'vue' {
TeamsTeam: typeof import('./components/teams/Team.vue')['default']
Tippy: typeof import('vue-tippy')['Tippy']
WorkspaceCurrent: typeof import('./components/workspace/Current.vue')['default']
WorkspacePersonalWorkspaceSelector: typeof import('./components/workspace/PersonalWorkspaceSelector.vue')['default']
WorkspaceSelector: typeof import('./components/workspace/Selector.vue')['default']
WorkspaceTestWorkspaceSelector: typeof import('./components/workspace/TestWorkspaceSelector.vue')['default']
}
}

View File

@@ -2,73 +2,74 @@
<div>
<header
ref="headerRef"
class="flex flex-1 flex-shrink-0 items-center justify-between space-x-2 overflow-x-auto overflow-y-hidden px-2 py-2"
class="grid grid-cols-5 grid-rows-1 gap-2 overflow-x-auto overflow-y-hidden p-2"
@mousedown.prevent="platform.ui?.appHeader?.onHeaderAreaClick?.()"
>
<div
class="inline-flex flex-1 items-center justify-start space-x-2"
class="col-span-2 flex items-center justify-between space-x-2"
:style="{
paddingTop: platform.ui?.appHeader?.paddingTop?.value,
paddingLeft: platform.ui?.appHeader?.paddingLeft?.value,
}"
>
<HoppButtonSecondary
class="!font-bold uppercase tracking-wide !text-secondaryDark hover:bg-primaryDark focus-visible:bg-primaryDark"
:label="t('app.name')"
to="/"
/>
</div>
<div class="inline-flex flex-1 items-center justify-center space-x-2">
<button
class="flex max-w-[15rem] flex-1 cursor-text items-center justify-between self-stretch rounded border border-dividerDark bg-primaryDark px-2 py-1 text-secondaryLight transition hover:border-dividerDark hover:bg-primaryLight hover:text-secondary focus-visible:border-dividerDark focus-visible:bg-primaryLight focus-visible:text-secondary"
@click="invokeAction('modals.search.toggle')"
>
<span class="inline-flex flex-1 items-center">
<icon-lucide-search class="svg-icons mr-2" />
{{ t("app.search") }}
</span>
<span class="flex space-x-1">
<kbd class="shortcut-key">{{ getPlatformSpecialKey() }}</kbd>
<kbd class="shortcut-key">K</kbd>
</span>
</button>
<HoppButtonSecondary
v-if="showInstallButton"
v-tippy="{ theme: 'tooltip' }"
:title="t('header.install_pwa')"
:icon="IconDownload"
class="rounded hover:bg-primaryDark focus-visible:bg-primaryDark"
@click="installPWA()"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip', allowHTML: true }"
:title="`${
mdAndLarger ? t('support.title') : t('app.options')
} <kbd>?</kbd>`"
:icon="IconLifeBuoy"
class="rounded hover:bg-primaryDark focus-visible:bg-primaryDark"
@click="invokeAction('modals.support.toggle')"
/>
</div>
<div class="inline-flex flex-1 items-center justify-end space-x-2">
<div
v-if="currentUser === null"
class="inline-flex items-center space-x-2"
>
<div class="flex">
<HoppButtonSecondary
:icon="IconUploadCloud"
:label="t('header.save_workspace')"
class="py-1.75 !focus-visible:text-green-600 !hover:text-green-600 hidden border border-green-600/25 bg-green-500/[.15] !text-green-500 hover:border-green-800/50 hover:bg-green-400/10 focus-visible:border-green-800/50 focus-visible:bg-green-400/10 md:flex"
@click="invokeAction('modals.login.toggle')"
/>
<HoppButtonPrimary
:label="t('header.login')"
@click="invokeAction('modals.login.toggle')"
class="!font-bold uppercase tracking-wide !text-secondaryDark hover:bg-primaryDark focus-visible:bg-primaryDark"
:label="t('app.name')"
to="/"
/>
</div>
<div v-else class="inline-flex items-center space-x-2">
</div>
<div class="col-span-1 flex items-center justify-between space-x-2">
<AppSpotlightSearch />
</div>
<div class="col-span-2 flex items-center justify-between space-x-2">
<div class="flex">
<HoppButtonSecondary
v-if="showInstallButton"
v-tippy="{ theme: 'tooltip' }"
:title="t('header.install_pwa')"
:icon="IconDownload"
class="rounded hover:bg-primaryDark focus-visible:bg-primaryDark"
@click="installPWA()"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip', allowHTML: true }"
:title="`${
mdAndLarger ? t('support.title') : t('app.options')
} <kbd>?</kbd>`"
:icon="IconLifeBuoy"
class="rounded hover:bg-primaryDark focus-visible:bg-primaryDark"
@click="invokeAction('modals.support.toggle')"
/>
</div>
<div
class="flex"
:class="{
'flex-row-reverse gap-2':
workspaceSelectorFlagEnabled && !currentUser,
}"
>
<div
v-if="currentUser === null"
class="inline-flex items-center space-x-2"
>
<HoppButtonSecondary
v-if="!workspaceSelectorFlagEnabled"
:icon="IconUploadCloud"
:label="t('header.save_workspace')"
class="!focus-visible:text-emerald-600 !hover:text-emerald-600 hidden h-8 border border-emerald-600/25 bg-emerald-500/10 !text-emerald-500 hover:border-emerald-600/20 hover:bg-emerald-600/20 focus-visible:border-emerald-600/20 focus-visible:bg-emerald-600/20 md:flex"
@click="invokeAction('modals.login.toggle')"
/>
<HoppButtonPrimary
:label="t('header.login')"
class="h-8"
@click="invokeAction('modals.login.toggle')"
/>
</div>
<TeamsMemberStack
v-if="
v-else-if="
currentUser !== null &&
workspace.type === 'team' &&
selectedTeam &&
selectedTeam.teamMembers.length > 1
@@ -79,145 +80,142 @@
@handle-click="handleTeamEdit()"
/>
<div
class="flex divide-x divide-green-600/25 rounded border border-green-600/25 bg-green-500/[.15] focus-within:divide-green-800/50 focus-within:border-green-800/50 focus-within:bg-green-400/10 hover:divide-green-800/50 hover:border-green-800/50 hover:bg-green-400/10"
v-if="workspaceSelectorFlagEnabled || currentUser"
class="inline-flex items-center space-x-2"
>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('team.invite_tooltip')"
:icon="IconUserPlus"
class="py-1.75 !focus-visible:text-green-600 !hover:text-green-600 !text-green-500"
@click="handleInvite()"
/>
<HoppButtonSecondary
v-if="
workspace.type === 'team' &&
selectedTeam &&
selectedTeam?.myRole === 'OWNER'
"
v-tippy="{ theme: 'tooltip' }"
:title="t('team.edit')"
:icon="IconSettings"
class="py-1.75 !focus-visible:text-green-600 !hover:text-green-600 !text-green-500"
@click="handleTeamEdit()"
/>
</div>
<tippy
interactive
trigger="click"
theme="popover"
:on-shown="() => accountActions.focus()"
>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('workspace.change')"
:label="mdAndLarger ? activeWorkspaceName : ``"
:icon="activeWorkspaceIcon"
class="select-wrapper !focus-visible:text-blue-600 !hover:text-blue-600 rounded border border-blue-600/25 bg-blue-500/[.15] py-[0.4375rem] pr-8 !text-blue-500 hover:border-blue-800/50 hover:bg-blue-400/10 focus-visible:border-blue-800/50 focus-visible:bg-blue-400/10"
/>
<template #content="{ hide }">
<div
ref="accountActions"
class="flex flex-col focus:outline-none"
tabindex="0"
@keyup.escape="hide()"
@click="hide()"
>
<WorkspaceSelector />
</div>
</template>
</tippy>
<span class="px-2">
<div
class="flex h-8 divide-x divide-emerald-600/25 rounded border border-emerald-600/25 bg-emerald-500/10 focus-within:divide-emerald-600/20 focus-within:border-emerald-600/20 focus-within:bg-emerald-600/20 hover:divide-emerald-600/20 hover:border-emerald-600/20 hover:bg-emerald-600/20"
>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('team.invite_tooltip')"
:icon="IconUserPlus"
class="!focus-visible:text-emerald-600 !hover:text-emerald-600 !text-emerald-500"
@click="handleInvite()"
/>
<HoppButtonSecondary
v-if="
currentUser &&
workspace.type === 'team' &&
selectedTeam &&
selectedTeam?.myRole === 'OWNER'
"
v-tippy="{ theme: 'tooltip' }"
:title="t('team.edit')"
:icon="IconSettings"
class="!focus-visible:text-emerald-600 !hover:text-emerald-600 !text-emerald-500"
@click="handleTeamEdit()"
/>
</div>
<tippy
interactive
trigger="click"
theme="popover"
:on-shown="() => tippyActions.focus()"
:on-shown="() => accountActions.focus()"
>
<HoppSmartPicture
v-if="currentUser.photoURL"
v-tippy="{
theme: 'tooltip',
}"
:url="currentUser.photoURL"
:alt="
currentUser.displayName ||
t('profile.default_hopp_displayname')
"
:title="
currentUser.displayName ||
currentUser.email ||
t('profile.default_hopp_displayname')
"
indicator
:indicator-styles="
network.isOnline ? 'bg-green-500' : 'bg-red-500'
"
/>
<HoppSmartPicture
v-else
v-tippy="{ theme: 'tooltip' }"
:title="
currentUser.displayName ||
currentUser.email ||
t('profile.default_hopp_displayname')
"
:initial="currentUser.displayName || currentUser.email"
indicator
:indicator-styles="
network.isOnline ? 'bg-green-500' : 'bg-red-500'
"
/>
<HoppSmartSelectWrapper
class="!text-blue-500 !focus-visible:text-blue-600 !hover:text-blue-600"
>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('workspace.change')"
:label="mdAndLarger ? workspaceName : ``"
:icon="workspace.type === 'personal' ? IconUser : IconUsers"
class="!focus-visible:text-blue-600 !hover:text-blue-600 h-8 rounded border border-blue-600/25 bg-blue-500/10 pr-8 !text-blue-500 hover:border-blue-600/20 hover:bg-blue-600/20 focus-visible:border-blue-600/20 focus-visible:bg-blue-600/20"
/>
</HoppSmartSelectWrapper>
<template #content="{ hide }">
<div
ref="tippyActions"
ref="accountActions"
class="flex flex-col focus:outline-none"
tabindex="0"
@keyup.p="profile.$el.click()"
@keyup.s="settings.$el.click()"
@keyup.l="logout.$el.click()"
@keyup.escape="hide()"
@click="hide()"
>
<div class="flex flex-col px-2 text-tiny">
<span class="inline-flex truncate font-semibold">
{{
currentUser.displayName ||
t("profile.default_hopp_displayname")
}}
</span>
<span class="inline-flex truncate text-secondaryLight">
{{ currentUser.email }}
</span>
</div>
<hr />
<HoppSmartItem
ref="profile"
to="/profile"
:icon="IconUser"
:label="t('navigation.profile')"
:shortcut="['P']"
@click="hide()"
/>
<HoppSmartItem
ref="settings"
to="/settings"
:icon="IconSettings"
:label="t('navigation.settings')"
:shortcut="['S']"
@click="hide()"
/>
<FirebaseLogout
ref="logout"
:shortcut="['L']"
@confirm-logout="hide()"
/>
<WorkspaceSelector />
</div>
</template>
</tippy>
</span>
<span v-if="currentUser" class="px-2">
<tippy
interactive
trigger="click"
theme="popover"
:on-shown="() => tippyActions.focus()"
>
<HoppSmartPicture
v-tippy="{
theme: 'tooltip',
}"
:name="currentUser.uid"
:title="
currentUser.displayName ||
currentUser.email ||
t('profile.default_hopp_displayname')
"
indicator
:indicator-styles="
network.isOnline ? 'bg-green-500' : 'bg-red-500'
"
/>
<template #content="{ hide }">
<div
ref="tippyActions"
class="flex flex-col focus:outline-none"
tabindex="0"
@keyup.p="profile.$el.click()"
@keyup.s="settings.$el.click()"
@keyup.l="logout.$el.click()"
@keyup.escape="hide()"
>
<div class="flex flex-col px-2">
<span class="inline-flex truncate font-semibold">
{{
currentUser.displayName ||
t("profile.default_hopp_displayname")
}}
</span>
<span
class="inline-flex truncate text-secondaryLight text-tiny"
>
{{ currentUser.email }}
</span>
</div>
<hr />
<HoppSmartItem
ref="profile"
to="/profile"
:icon="IconUser"
:label="t('navigation.profile')"
:shortcut="['P']"
@click="hide()"
/>
<HoppSmartItem
ref="settings"
to="/settings"
:icon="IconSettings"
:label="t('navigation.settings')"
:shortcut="['S']"
@click="hide()"
/>
<FirebaseLogout
ref="logout"
:shortcut="['L']"
@confirm-logout="hide()"
/>
</div>
</template>
</tippy>
</span>
</div>
</div>
</div>
</header>
<AppBanner v-if="bannerContent" :banner="bannerContent" />
<AppBanner
v-if="bannerContent"
:banner="bannerContent"
@dismiss="dismissOfflineBanner"
/>
<TeamsModal :show="showTeamsModal" @hide-modal="showTeamsModal = false" />
<TeamsInvite
v-if="workspace.type === 'team' && workspace.teamID"
@@ -233,7 +231,6 @@
@invite-team="inviteTeam(editingTeamName, editingTeamID)"
@refetch-teams="refetchTeams"
/>
<HoppSmartConfirmModal
:show="confirmRemove"
:title="t('confirm.remove_team')"
@@ -247,35 +244,40 @@
import { useI18n } from "@composables/i18n"
import { useReadonlyStream } from "@composables/stream"
import { defineActionHandler, invokeAction } from "@helpers/actions"
import { WorkspaceService } from "~/services/workspace.service"
import { useService } from "dioc/vue"
import { installPWA, pwaDefferedPrompt } from "@modules/pwa"
import { breakpointsTailwind, useBreakpoints, useNetwork } from "@vueuse/core"
import { useService } from "dioc/vue"
import * as TE from "fp-ts/TaskEither"
import { pipe } from "fp-ts/function"
import { computed, reactive, ref, watch } from "vue"
import { useToast } from "~/composables/toast"
import { GetMyTeamsQuery, TeamMemberRole } from "~/helpers/backend/graphql"
import { deleteTeam as backendDeleteTeam } from "~/helpers/backend/mutations/Team"
import { getPlatformSpecialKey } from "~/helpers/platformutils"
import { platform } from "~/platform"
import {
BANNER_PRIORITY_HIGH,
BannerContent,
BannerService,
} from "~/services/banner.service"
import { NewWorkspaceService } from "~/services/new-workspace"
import { WorkspaceService } from "~/services/workspace.service"
import IconDownload from "~icons/lucide/download"
import IconLifeBuoy from "~icons/lucide/life-buoy"
import IconSettings from "~icons/lucide/settings"
import IconUploadCloud from "~icons/lucide/upload-cloud"
import IconUser from "~icons/lucide/user"
import IconUserPlus from "~icons/lucide/user-plus"
import IconUsers from "~icons/lucide/users"
import { pipe } from "fp-ts/function"
import * as TE from "fp-ts/TaskEither"
import { deleteTeam as backendDeleteTeam } from "~/helpers/backend/mutations/Team"
import {
BannerService,
BannerContent,
BANNER_PRIORITY_HIGH,
} from "~/services/banner.service"
const t = useI18n()
const toast = useToast()
/**
* Feature flag to enable the workspace selector login conversion
*/
const workspaceSelectorFlagEnabled = computed(
() => !!platform.platformFeatureFlags.workspaceSwitcherLogin?.value
)
/**
* Once the PWA code is initialized, this holds a method
* that can be called to show the user the installation
@@ -294,10 +296,11 @@ const bannerContent = computed(() => banner.content.value?.content)
let bannerID: number | null = null
const offlineBanner: BannerContent = {
type: "info",
type: "warning",
text: (t) => t("helpers.offline"),
alternateText: (t) => t("helpers.offline_short"),
score: BANNER_PRIORITY_HIGH,
dismissible: true,
}
const network = reactive(useNetwork())
@@ -314,6 +317,8 @@ watch(isOnline, () => {
}
})
const dismissOfflineBanner = () => banner.removeBanner(bannerID!)
const currentUser = useReadonlyStream(
platform.auth.getProbableUserStream(),
platform.auth.getProbableUser()
@@ -331,6 +336,12 @@ const myTeams = useReadonlyStream(teamListAdapter.teamList$, null)
const workspace = workspaceService.currentWorkspace
const workspaceName = computed(() => {
return workspace.value.type === "personal"
? t("workspace.personal")
: workspace.value.teamName
})
const refetchTeams = () => {
teamListAdapter.fetchList()
}
@@ -365,23 +376,6 @@ watch(
}
)
const newWorkspaceService = useService(NewWorkspaceService)
const activeWorkspaceName = computed(() => {
const activeWorkspaceHandleRef =
newWorkspaceService.activeWorkspaceHandle.value?.get()
if (activeWorkspaceHandleRef?.value.type === "ok") {
return activeWorkspaceHandleRef.value.data.name
}
return t("workspace.no_workspace")
})
const activeWorkspaceIcon = computed(() => {
return newWorkspaceService.activeWorkspaceDecor.value?.value.headerCurrentIcon
})
const showModalInvite = ref(false)
const showModalEdit = ref(false)

View File

@@ -30,7 +30,7 @@ import { defineStep } from "~/composables/step-components"
import { useI18n } from "~/composables/i18n"
import { useToast } from "~/composables/toast"
import { restCollections$ } from "~/newstore/collections"
import { appendRESTCollections, restCollections$ } from "~/newstore/collections"
import MyCollectionImport from "~/components/importExport/ImportExportSteps/MyCollectionImport.vue"
import IconFolderPlus from "~icons/lucide/folder-plus"
@@ -47,14 +47,13 @@ import { getTeamCollectionJSON } from "~/helpers/backend/helpers"
import { platform } from "~/platform"
import { initializeDownloadFile } from "~/helpers/import-export/export"
import { initializeDownloadCollection } from "~/helpers/import-export/export"
import { gistExporter } from "~/helpers/import-export/export/gist"
import { myCollectionsExporter } from "~/helpers/import-export/export/myCollections"
import { teamCollectionsExporter } from "~/helpers/import-export/export/teamCollections"
import { GistSource } from "~/helpers/import-export/import/import-sources/GistSource"
import { ImporterOrExporter } from "~/components/importExport/types"
import { useService } from "dioc/vue"
import { NewWorkspaceService } from "~/services/new-workspace"
import { TeamWorkspace } from "~/services/workspace.service"
const t = useI18n()
@@ -85,45 +84,15 @@ const currentUser = useReadonlyStream(
const myCollections = useReadonlyStream(restCollections$, [])
const workspaceService = useService(NewWorkspaceService)
const activeWorkspaceHandle = workspaceService.activeWorkspaceHandle
const showImportFailedError = () => {
toast.error(t("import.failed"))
}
const handleImportToStore = async (collections: HoppCollection[]) => {
if (props.collectionsType.type === "my-collections") {
if (!activeWorkspaceHandle.value) {
return
}
const collectionHandleResult = await workspaceService.importRESTCollections(
activeWorkspaceHandle.value,
collections
)
if (E.isLeft(collectionHandleResult)) {
// INVALID_WORKSPACE_HANDLE
return toast.error(t("import.failed"))
}
const resultHandle = collectionHandleResult.right
const requestHandleRef = resultHandle.get()
if (requestHandleRef.value.type === "invalid") {
// WORKSPACE_INVALIDATED
}
toast.success(t("state.file_imported"))
emit("hide-modal")
return
}
const importResult = await importToTeamsWorkspace(collections)
const importResult =
props.collectionsType.type === "my-collections"
? await importToPersonalWorkspace(collections)
: await importToTeamsWorkspace(collections)
if (E.isRight(importResult)) {
toast.success(t("state.file_imported"))
@@ -133,6 +102,13 @@ const handleImportToStore = async (collections: HoppCollection[]) => {
}
}
const importToPersonalWorkspace = (collections: HoppCollection[]) => {
appendRESTCollections(collections)
return E.right({
success: true,
})
}
function translateToTeamCollectionFormat(x: HoppCollection) {
const folders: HoppCollection[] = (x.folders ?? []).map(
translateToTeamCollectionFormat
@@ -412,34 +388,28 @@ const HoppMyCollectionsExporter: ImporterOrExporter = {
applicableTo: ["personal-workspace"],
isLoading: isHoppMyCollectionExporterInProgress,
},
action: async () => {
action: () => {
if (!myCollections.value.length) {
return toast.error(t("error.no_collections_to_export"))
}
if (!activeWorkspaceHandle.value) {
return
}
isHoppMyCollectionExporterInProgress.value = true
const result = await workspaceService.exportRESTCollections(
activeWorkspaceHandle.value,
myCollections.value
const message = initializeDownloadCollection(
myCollectionsExporter(myCollections.value),
"Collections"
)
if (E.isLeft(result)) {
// INVALID_WORKSPACE_HANDLE
if (E.isRight(message)) {
toast.success(t(message.right))
platform.analytics?.logEvent({
type: "HOPP_EXPORT_COLLECTION",
exporter: "json",
platform: "rest",
})
}
toast.success(t("state.download_started"))
platform.analytics?.logEvent({
type: "HOPP_EXPORT_COLLECTION",
exporter: "json",
platform: "rest",
})
isHoppMyCollectionExporterInProgress.value = false
},
}
@@ -473,7 +443,10 @@ const HoppTeamCollectionsExporter: ImporterOrExporter = {
return toast.error(t("error.no_collections_to_export"))
}
initializeDownloadFile(exportCollectionsToJSON, "team-collections")
initializeDownloadCollection(
exportCollectionsToJSON,
"team-collections"
)
platform.analytics?.logEvent({
type: "HOPP_EXPORT_COLLECTION",
@@ -512,7 +485,7 @@ const HoppGistCollectionsExporter: ImporterOrExporter = {
const collectionJSON = await getCollectionJSON()
const accessToken = currentUser.value?.accessToken
if (!accessToken || E.isLeft(collectionJSON)) {
if (!accessToken) {
toast.error(t("error.something_went_wrong"))
isHoppGistCollectionExporterInProgress.value = false
return
@@ -608,7 +581,6 @@ const selectedTeamID = computed(() => {
})
const getCollectionJSON = async () => {
// TODO: Implement `getRESTCollectionJSONView` for team workspace
if (
props.collectionsType.type === "team-collections" &&
props.collectionsType.selectedTeam?.teamID
@@ -619,33 +591,11 @@ const getCollectionJSON = async () => {
return E.isRight(res)
? E.right(res.right.exportCollectionsToJSON)
: E.left(res.left.error.toString())
: E.left(res.left)
}
if (props.collectionsType.type === "my-collections") {
if (!activeWorkspaceHandle.value) {
return E.left("INVALID_WORKSPACE_HANDLE")
}
const collectionJSONHandleResult =
await workspaceService.getRESTCollectionJSONView(
activeWorkspaceHandle.value
)
if (E.isLeft(collectionJSONHandleResult)) {
return E.left(collectionJSONHandleResult.left.error)
}
const collectionJSONHandle = collectionJSONHandleResult.right
const collectionJSONHandleRef = collectionJSONHandle.get()
if (collectionJSONHandleRef.value.type === "invalid") {
// WORKSPACE_INVALIDATED
return E.left("WORKSPACE_INVALIDATED")
}
return E.right(collectionJSONHandleRef.value.data.content)
return E.right(JSON.stringify(myCollections.value, null, 2))
}
return E.left("INVALID_SELECTED_TEAM_OR_INVALID_COLLECTION_TYPE")

View File

@@ -75,7 +75,7 @@ import {
import { useVModel } from "@vueuse/core"
import { useService } from "dioc/vue"
import { clone } from "lodash-es"
import { ref, toRefs, watch } from "vue"
import { ref, watch } from "vue"
import { HoppInheritedProperty } from "~/helpers/types/HoppInheritedProperties"
import { PersistenceService } from "~/services/persistence"
@@ -100,15 +100,11 @@ const props = withDefaults(
editingProperties: EditingProperties | null
source: "REST" | "GraphQL"
modelValue: string
// TODO: Purpose of this prop is to maintain backwards compatibility
// To be removed after porting all usages of this component
emitWithFullCollection: boolean
}>(),
{
show: false,
loadingState: false,
editingProperties: null,
emitWithFullCollection: true,
}
)
@@ -182,24 +178,15 @@ watch(
const saveEditedCollection = () => {
if (!props.editingProperties) return
const finalCollection = clone(editableCollection.value)
delete finalCollection.body
const { path } = toRefs(props.editingProperties)
const collection = {
path: path.value,
path: props.editingProperties.path,
collection: {
...props.editingProperties.collection,
...finalCollection,
},
isRootCollection: props.editingProperties.isRootCollection,
}
const data = props.emitWithFullCollection
? collection
: { ...finalCollection, collIndexPath: path.value }
emit("set-collection-properties", data as EditingProperties)
emit("set-collection-properties", collection as EditingProperties)
persistenceService.removeLocalConfig("unsaved_collection_properties")
}

View File

@@ -20,25 +20,19 @@
<label class="p-4">
{{ t("collection.select_location") }}
</label>
<!-- <CollectionsGraphql
<CollectionsGraphql
v-if="mode === 'graphql'"
:picked="picked"
:save-request="true"
@select="onSelect"
/> -->
<!-- <Collections
/>
<Collections
v-else
:picked="picked"
:save-request="true"
@select="onSelect"
@update-team="updateTeam"
@update-collection-type="updateCollectionType"
/> -->
<NewCollections
:picked="picked"
:save-request="true"
platform="rest"
@select="onSelect"
/>
</div>
</template>
@@ -71,31 +65,40 @@ import {
} from "@hoppscotch/data"
import { computedWithControl } from "@vueuse/core"
import { useService } from "dioc/vue"
import * as E from "fp-ts/Either"
import * as TE from "fp-ts/TaskEither"
import { pipe } from "fp-ts/function"
import { cloneDeep } from "lodash-es"
import { computed, nextTick, reactive, ref, watch } from "vue"
import { GQLError } from "~/helpers/backend/GQLClient"
import {
createRequestInCollection,
updateTeamRequest,
} from "~/helpers/backend/mutations/TeamRequest"
import { Picked } from "~/helpers/types/HoppPicked"
import { cascadeParentCollectionForHeaderAuth } from "~/newstore/collections"
import { NewWorkspaceService } from "~/services/new-workspace"
import {
cascadeParentCollectionForHeaderAuth,
editGraphqlRequest,
editRESTRequest,
saveGraphqlRequestAs,
saveRESTRequestAs,
} from "~/newstore/collections"
import { platform } from "~/platform"
import { GQLTabService } from "~/services/tab/graphql"
import { RESTTabService } from "~/services/tab/rest"
import { TeamWorkspace } from "~/services/workspace.service"
const t = useI18n()
const toast = useToast()
const RESTTabs = useService(RESTTabService)
const GQLTabs = useService(GQLTabService)
const workspaceService = useService(NewWorkspaceService)
// type SelectedTeam = GetMyTeamsQuery["myTeams"][number] | undefined
// type CollectionType =
// | {
// type: "team-collections"
// selectedTeam: SelectedTeam
// }
// | { type: "my-collections"; selectedTeam: undefined }
type CollectionType =
| {
type: "team-collections"
selectedTeam: TeamWorkspace
}
| { type: "my-collections"; selectedTeam: undefined }
const props = withDefaults(
defineProps<{
@@ -163,10 +166,10 @@ const requestData = reactive({
requestIndex: undefined as number | undefined,
})
// const collectionsType = ref<CollectionType>({
// type: "my-collections",
// selectedTeam: undefined,
// })
const collectionsType = ref<CollectionType>({
type: "my-collections",
selectedTeam: undefined,
})
const picked = ref<Picked | null>(null)
@@ -187,14 +190,13 @@ watch(
}
)
// TODO: To be removed
// const updateTeam = (newTeam: SelectedTeam) => {
// collectionsType.value.selectedTeam = newTeam
// }
const updateTeam = (newTeam: TeamWorkspace) => {
collectionsType.value.selectedTeam = newTeam
}
// const updateCollectionType = (type: CollectionType["type"]) => {
// collectionsType.value.type = type
// }
const updateCollectionType = (type: CollectionType["type"]) => {
collectionsType.value.type = type
}
const onSelect = (pickedVal: Picked | null) => {
picked.value = pickedVal
@@ -210,109 +212,66 @@ const saveRequestAs = async () => {
return
}
const updatedRequest =
const requestUpdated =
props.mode === "rest"
? cloneDeep(RESTTabs.currentActiveTab.value.document.request)
: cloneDeep(GQLTabs.currentActiveTab.value.document.request)
updatedRequest.name = requestName.value
requestUpdated.name = requestName.value
if (!workspaceService.activeWorkspaceHandle.value) {
return
}
if (
picked.value.pickedType === "my-collection" ||
picked.value.pickedType === "my-folder"
) {
if (!isHoppRESTRequest(updatedRequest))
if (picked.value.pickedType === "my-collection") {
if (!isHoppRESTRequest(requestUpdated))
throw new Error("requestUpdated is not a REST Request")
const collectionPathIndex =
picked.value.pickedType === "my-collection"
? picked.value.collectionIndex.toString()
: picked.value.folderPath
const collectionHandleResult = await workspaceService.getCollectionHandle(
workspaceService.activeWorkspaceHandle.value,
collectionPathIndex
const insertionIndex = saveRESTRequestAs(
`${picked.value.collectionIndex}`,
requestUpdated
)
if (E.isLeft(collectionHandleResult)) {
// INVALID_WORKSPACE_HANDLE | INVALID_COLLECTION_ID | INVALID_PATH
return
}
const collectionHandle = collectionHandleResult.right
const requestHandleResult = await workspaceService.createRESTRequest(
collectionHandle,
updatedRequest
)
if (E.isLeft(requestHandleResult)) {
// WORKSPACE_INVALIDATED | INVALID_COLLECTION_HANDLE
return
}
const requestHandle = requestHandleResult.right
const requestHandleRef = requestHandle.get()
if (requestHandleRef.value.type === "invalid") {
// WORKSPACE_INVALIDATED | INVALID_COLLECTION_HANDLE
return
}
RESTTabs.currentActiveTab.value.document = {
request: updatedRequest,
request: requestUpdated,
isDirty: false,
saveContext: {
originLocation: "workspace-user-collection",
requestHandle,
originLocation: "user-collection",
folderPath: `${picked.value.collectionIndex}`,
requestIndex: insertionIndex,
},
}
const { auth, headers } = cascadeParentCollectionForHeaderAuth(
`${picked.value.collectionIndex}`,
"rest"
)
RESTTabs.currentActiveTab.value.document.inheritedProperties = {
auth,
headers,
}
platform.analytics?.logEvent({
type: "HOPP_SAVE_REQUEST",
createdNow: true,
platform: "rest",
workspaceType: "personal",
})
requestSaved()
} else if (picked.value.pickedType === "my-request") {
if (!isHoppRESTRequest(updatedRequest))
} else if (picked.value.pickedType === "my-folder") {
if (!isHoppRESTRequest(requestUpdated))
throw new Error("requestUpdated is not a REST Request")
const requestHandleResult = await workspaceService.getRequestHandle(
workspaceService.activeWorkspaceHandle.value,
`${picked.value.folderPath}/${picked.value.requestIndex.toString()}`
const insertionIndex = saveRESTRequestAs(
picked.value.folderPath,
requestUpdated
)
if (E.isLeft(requestHandleResult)) {
// INVALID_COLLECTION_HANDLE | INVALID_REQUEST_ID | REQUEST_NOT_FOUND
return
}
const requestHandle = requestHandleResult.right
const requestHandleRef = requestHandle.get()
if (requestHandleRef.value.type === "invalid") {
// WORKSPACE_INVALIDATED
return
}
const updateRequestResult = await workspaceService.updateRESTRequest(
requestHandle,
updatedRequest
)
if (E.isLeft(updateRequestResult)) {
// WORKSPACE_INVALIDATED | INVALID_REQUEST_HANDLE
return
}
RESTTabs.currentActiveTab.value.document = {
request: updatedRequest,
request: requestUpdated,
isDirty: false,
saveContext: {
originLocation: "workspace-user-collection",
requestHandle,
originLocation: "user-collection",
folderPath: picked.value.folderPath,
requestIndex: insertionIndex,
},
}
@@ -326,147 +285,190 @@ const saveRequestAs = async () => {
headers,
}
platform.analytics?.logEvent({
type: "HOPP_SAVE_REQUEST",
createdNow: true,
platform: "rest",
workspaceType: "personal",
})
requestSaved()
} else if (picked.value.pickedType === "my-request") {
if (!isHoppRESTRequest(requestUpdated))
throw new Error("requestUpdated is not a REST Request")
editRESTRequest(
picked.value.folderPath,
picked.value.requestIndex,
requestUpdated
)
RESTTabs.currentActiveTab.value.document = {
request: requestUpdated,
isDirty: false,
saveContext: {
originLocation: "user-collection",
folderPath: picked.value.folderPath,
requestIndex: picked.value.requestIndex,
},
}
const { auth, headers } = cascadeParentCollectionForHeaderAuth(
picked.value.folderPath,
"rest"
)
RESTTabs.currentActiveTab.value.document.inheritedProperties = {
auth,
headers,
}
platform.analytics?.logEvent({
type: "HOPP_SAVE_REQUEST",
createdNow: false,
platform: "rest",
workspaceType: "personal",
})
requestSaved()
} else if (picked.value.pickedType === "teams-collection") {
if (!isHoppRESTRequest(requestUpdated))
throw new Error("requestUpdated is not a REST Request")
updateTeamCollectionOrFolder(picked.value.collectionID, requestUpdated)
platform.analytics?.logEvent({
type: "HOPP_SAVE_REQUEST",
createdNow: true,
platform: "rest",
workspaceType: "team",
})
} else if (picked.value.pickedType === "teams-folder") {
if (!isHoppRESTRequest(requestUpdated))
throw new Error("requestUpdated is not a REST Request")
updateTeamCollectionOrFolder(picked.value.folderID, requestUpdated)
platform.analytics?.logEvent({
type: "HOPP_SAVE_REQUEST",
createdNow: true,
platform: "rest",
workspaceType: "team",
})
} else if (picked.value.pickedType === "teams-request") {
if (!isHoppRESTRequest(requestUpdated))
throw new Error("requestUpdated is not a REST Request")
if (
collectionsType.value.type !== "team-collections" ||
!collectionsType.value.selectedTeam
)
throw new Error("Collections Type mismatch")
modalLoadingState.value = true
const data = {
request: JSON.stringify(requestUpdated),
title: requestUpdated.name,
}
platform.analytics?.logEvent({
type: "HOPP_SAVE_REQUEST",
createdNow: false,
platform: "rest",
workspaceType: "team",
})
pipe(
updateTeamRequest(picked.value.requestID, data),
TE.match(
(err: GQLError<string>) => {
toast.error(`${getErrorMessage(err)}`)
modalLoadingState.value = false
},
() => {
modalLoadingState.value = false
requestSaved()
}
)
)()
} else if (picked.value.pickedType === "gql-my-request") {
// TODO: Check for GQL request ?
editGraphqlRequest(
picked.value.folderPath,
picked.value.requestIndex,
requestUpdated as HoppGQLRequest
)
platform.analytics?.logEvent({
type: "HOPP_SAVE_REQUEST",
createdNow: false,
platform: "gql",
workspaceType: "team",
})
const { auth, headers } = cascadeParentCollectionForHeaderAuth(
picked.value.folderPath,
"graphql"
)
GQLTabs.currentActiveTab.value.document.inheritedProperties = {
auth,
headers,
}
requestSaved()
} else if (picked.value.pickedType === "gql-my-folder") {
// TODO: Check for GQL request ?
saveGraphqlRequestAs(
picked.value.folderPath,
requestUpdated as HoppGQLRequest
)
platform.analytics?.logEvent({
type: "HOPP_SAVE_REQUEST",
createdNow: true,
platform: "gql",
workspaceType: "team",
})
const { auth, headers } = cascadeParentCollectionForHeaderAuth(
picked.value.folderPath,
"graphql"
)
GQLTabs.currentActiveTab.value.document.inheritedProperties = {
auth,
headers,
}
requestSaved()
} else if (picked.value.pickedType === "gql-my-collection") {
// TODO: Check for GQL request ?
saveGraphqlRequestAs(
`${picked.value.collectionIndex}`,
requestUpdated as HoppGQLRequest
)
platform.analytics?.logEvent({
type: "HOPP_SAVE_REQUEST",
createdNow: true,
platform: "gql",
workspaceType: "team",
})
const { auth, headers } = cascadeParentCollectionForHeaderAuth(
`${picked.value.collectionIndex}`,
"graphql"
)
GQLTabs.currentActiveTab.value.document.inheritedProperties = {
auth,
headers,
}
requestSaved()
}
// TODO: To be removed
// else if (picked.value.pickedType === "teams-collection") {
// if (!isHoppRESTRequest(updatedRequest))
// throw new Error("requestUpdated is not a REST Request")
// updateTeamCollectionOrFolder(picked.value.collectionID, updatedRequest)
// platform.analytics?.logEvent({
// type: "HOPP_SAVE_REQUEST",
// createdNow: true,
// platform: "rest",
// workspaceType: "team",
// })
// } else if (picked.value.pickedType === "teams-folder") {
// if (!isHoppRESTRequest(updatedRequest))
// throw new Error("requestUpdated is not a REST Request")
// updateTeamCollectionOrFolder(picked.value.folderID, updatedRequest)
// platform.analytics?.logEvent({
// type: "HOPP_SAVE_REQUEST",
// createdNow: true,
// platform: "rest",
// workspaceType: "team",
// })
// } else if (picked.value.pickedType === "teams-request") {
// if (!isHoppRESTRequest(updatedRequest))
// throw new Error("requestUpdated is not a REST Request")
// if (
// collectionsType.value.type !== "team-collections" ||
// !collectionsType.value.selectedTeam
// )
// throw new Error("Collections Type mismatch")
// modalLoadingState.value = true
// const data = {
// request: JSON.stringify(updatedRequest),
// title: updatedRequest.name,
// }
// platform.analytics?.logEvent({
// type: "HOPP_SAVE_REQUEST",
// createdNow: false,
// platform: "rest",
// workspaceType: "team",
// })
// pipe(
// updateTeamRequest(picked.value.requestID, data),
// TE.match(
// (err: GQLError<string>) => {
// toast.error(`${getErrorMessage(err)}`)
// modalLoadingState.value = false
// },
// () => {
// modalLoadingState.value = false
// requestSaved()
// }
// )
// )()
// } else if (picked.value.pickedType === "gql-my-request") {
// // TODO: Check for GQL request ?
// editGraphqlRequest(
// picked.value.folderPath,
// picked.value.requestIndex,
// updatedRequest as HoppGQLRequest
// )
// platform.analytics?.logEvent({
// type: "HOPP_SAVE_REQUEST",
// createdNow: false,
// platform: "gql",
// workspaceType: "team",
// })
// const { auth, headers } = cascadeParentCollectionForHeaderAuth(
// picked.value.folderPath,
// "graphql"
// )
// GQLTabs.currentActiveTab.value.document.inheritedProperties = {
// auth,
// headers,
// }
// requestSaved()
// } else if (picked.value.pickedType === "gql-my-folder") {
// // TODO: Check for GQL request ?
// saveGraphqlRequestAs(
// picked.value.folderPath,
// updatedRequest as HoppGQLRequest
// )
// platform.analytics?.logEvent({
// type: "HOPP_SAVE_REQUEST",
// createdNow: true,
// platform: "gql",
// workspaceType: "team",
// })
// const { auth, headers } = cascadeParentCollectionForHeaderAuth(
// picked.value.folderPath,
// "graphql"
// )
// GQLTabs.currentActiveTab.value.document.inheritedProperties = {
// auth,
// headers,
// }
// requestSaved()
// } else if (picked.value.pickedType === "gql-my-collection") {
// // TODO: Check for GQL request ?
// saveGraphqlRequestAs(
// `${picked.value.collectionIndex}`,
// updatedRequest as HoppGQLRequest
// )
// platform.analytics?.logEvent({
// type: "HOPP_SAVE_REQUEST",
// createdNow: true,
// platform: "gql",
// workspaceType: "team",
// })
// const { auth, headers } = cascadeParentCollectionForHeaderAuth(
// `${picked.value.collectionIndex}`,
// "graphql"
// )
// GQLTabs.currentActiveTab.value.document.inheritedProperties = {
// auth,
// headers,
// }
// requestSaved()
// }
}
/**
@@ -474,50 +476,50 @@ const saveRequestAs = async () => {
* @param collectionID - ID of the collection or folder
* @param requestUpdated - Updated request
*/
// const updateTeamCollectionOrFolder = (
// collectionID: string,
// requestUpdated: HoppRESTRequest
// ) => {
// if (
// collectionsType.value.type !== "team-collections" ||
// !collectionsType.value.selectedTeam
// )
// throw new Error("Collections Type mismatch")
const updateTeamCollectionOrFolder = (
collectionID: string,
requestUpdated: HoppRESTRequest
) => {
if (
collectionsType.value.type !== "team-collections" ||
!collectionsType.value.selectedTeam
)
throw new Error("Collections Type mismatch")
// modalLoadingState.value = true
modalLoadingState.value = true
// const data = {
// title: requestUpdated.name,
// request: JSON.stringify(requestUpdated),
// teamID: collectionsType.value.selectedTeam.id,
// }
// pipe(
// createRequestInCollection(collectionID, data),
// TE.match(
// (err: GQLError<string>) => {
// toast.error(`${getErrorMessage(err)}`)
// modalLoadingState.value = false
// },
// (result) => {
// const { createRequestInCollection } = result
const data = {
title: requestUpdated.name,
request: JSON.stringify(requestUpdated),
teamID: collectionsType.value.selectedTeam.teamID,
}
pipe(
createRequestInCollection(collectionID, data),
TE.match(
(err: GQLError<string>) => {
toast.error(`${getErrorMessage(err)}`)
modalLoadingState.value = false
},
(result) => {
const { createRequestInCollection } = result
// RESTTabs.currentActiveTab.value.document = {
// request: requestUpdated,
// isDirty: false,
// saveContext: {
// originLocation: "team-collection",
// requestID: createRequestInCollection.id,
// collectionID: createRequestInCollection.collection.id,
// teamID: createRequestInCollection.collection.team.id,
// },
// }
RESTTabs.currentActiveTab.value.document = {
request: requestUpdated,
isDirty: false,
saveContext: {
originLocation: "team-collection",
requestID: createRequestInCollection.id,
collectionID: createRequestInCollection.collection.id,
teamID: createRequestInCollection.collection.team.id,
},
}
// modalLoadingState.value = false
// requestSaved()
// }
// )
// )()
// }
modalLoadingState.value = false
requestSaved()
}
)
)()
}
const requestSaved = () => {
toast.success(`${t("request.added")}`)
@@ -532,24 +534,24 @@ const hideModal = () => {
emit("hide-modal")
}
// const getErrorMessage = (err: GQLError<string>) => {
// console.error(err)
// if (err.type === "network_error") {
// return t("error.network_error")
// }
// switch (err.error) {
// case "team_coll/short_title":
// return t("collection.name_length_insufficient")
// case "team/invalid_coll_id":
// return t("team.invalid_id")
// case "team/not_required_role":
// return t("profile.no_permission")
// case "team_req/not_required_role":
// return t("profile.no_permission")
// case "Forbidden resource":
// return t("profile.no_permission")
// default:
// return t("error.something_went_wrong")
// }
// }
const getErrorMessage = (err: GQLError<string>) => {
console.error(err)
if (err.type === "network_error") {
return t("error.network_error")
}
switch (err.error) {
case "team_coll/short_title":
return t("collection.name_length_insufficient")
case "team/invalid_coll_id":
return t("team.invalid_id")
case "team/not_required_role":
return t("profile.no_permission")
case "team_req/not_required_role":
return t("profile.no_permission")
case "Forbidden resource":
return t("profile.no_permission")
default:
return t("error.something_went_wrong")
}
}
</script>

View File

@@ -21,7 +21,7 @@ import { GistSource } from "~/helpers/import-export/import/import-sources/GistSo
import IconFolderPlus from "~icons/lucide/folder-plus"
import IconUser from "~icons/lucide/user"
import { initializeDownloadFile } from "~/helpers/import-export/export"
import { initializeDownloadCollection } from "~/helpers/import-export/export"
import { useReadonlyStream } from "~/composables/stream"
import { platform } from "~/platform"
@@ -133,12 +133,12 @@ const GqlCollectionsHoppExporter: ImporterOrExporter = {
disabled: false,
applicableTo: ["personal-workspace", "team-workspace"],
},
action: async () => {
action: () => {
if (!gqlCollections.value.length) {
return toast.error(t("error.no_collections_to_export"))
}
const message = await initializeDownloadFile(
const message = initializeDownloadCollection(
gqlCollectionsExporter(gqlCollections.value),
"GQLCollections"
)

View File

@@ -37,7 +37,7 @@ import IconFolderPlus from "~icons/lucide/folder-plus"
import IconPostman from "~icons/hopp/postman"
import IconInsomnia from "~icons/hopp/insomnia"
import IconUser from "~icons/lucide/user"
import { initializeDownloadFile } from "~/helpers/import-export/export"
import { initializeDownloadCollection } from "~/helpers/import-export/export"
import { computed } from "vue"
import { useReadonlyStream } from "~/composables/stream"
import { environmentsExporter } from "~/helpers/import-export/export/environments"
@@ -230,12 +230,12 @@ const HoppEnvironmentsExport: ImporterOrExporter = {
disabled: false,
applicableTo: ["personal-workspace", "team-workspace"],
},
action: async () => {
action: () => {
if (!environmentJson.value.length) {
return toast.error(t("error.no_environments_to_export"))
}
const message = await initializeDownloadFile(
const message = initializeDownloadCollection(
environmentsExporter(environmentJson.value),
"Environments"
)

View File

@@ -236,28 +236,16 @@ import { useI18n } from "@composables/i18n"
import { useSetting } from "@composables/settings"
import { useReadonlyStream, useStreamSubscriber } from "@composables/stream"
import { useToast } from "@composables/toast"
import { HoppRESTRequest } from "@hoppscotch/data"
import { useVModel } from "@vueuse/core"
import { useService } from "dioc/vue"
import * as E from "fp-ts/Either"
import { Ref, computed, onUnmounted, ref } from "vue"
import { runRESTRequest$ } from "~/helpers/RequestRunner"
import { Ref, computed, ref, onUnmounted } from "vue"
import { defineActionHandler, invokeAction } from "~/helpers/actions"
import { runMutation } from "~/helpers/backend/GQLClient"
import { UpdateRequestDocument } from "~/helpers/backend/graphql"
import { getPlatformSpecialKey as getSpecialKey } from "~/helpers/platformutils"
import { getDefaultRESTRequest } from "~/helpers/rest/default"
import { HoppRESTDocument } from "~/helpers/rest/document"
import { getMethodLabelColor } from "~/helpers/rest/labelColoring"
import { runRESTRequest$ } from "~/helpers/RequestRunner"
import { HoppRESTResponse } from "~/helpers/types/HoppRESTResponse"
import { RESTHistoryEntry, restHistory$ } from "~/newstore/history"
import { platform } from "~/platform"
import { InspectionService } from "~/services/inspection"
import { InterceptorService } from "~/services/interceptor.service"
import { NewWorkspaceService } from "~/services/new-workspace"
import { HoppTab } from "~/services/tab"
import { RESTTabService } from "~/services/tab/rest"
import { WorkspaceService } from "~/services/workspace.service"
import { editRESTRequest } from "~/newstore/collections"
import IconChevronDown from "~icons/lucide/chevron-down"
import IconCode2 from "~icons/lucide/code-2"
import IconFileCode from "~icons/lucide/file-code"
@@ -265,10 +253,21 @@ import IconFolderPlus from "~icons/lucide/folder-plus"
import IconRotateCCW from "~icons/lucide/rotate-ccw"
import IconSave from "~icons/lucide/save"
import IconShare2 from "~icons/lucide/share-2"
import { getDefaultRESTRequest } from "~/helpers/rest/default"
import { RESTHistoryEntry, restHistory$ } from "~/newstore/history"
import { platform } from "~/platform"
import { HoppRESTRequest } from "@hoppscotch/data"
import { useService } from "dioc/vue"
import { InspectionService } from "~/services/inspection"
import { InterceptorService } from "~/services/interceptor.service"
import { HoppTab } from "~/services/tab"
import { HoppRESTDocument } from "~/helpers/rest/document"
import { RESTTabService } from "~/services/tab/rest"
import { getMethodLabelColor } from "~/helpers/rest/labelColoring"
import { WorkspaceService } from "~/services/workspace.service"
const t = useI18n()
const interceptorService = useService(InterceptorService)
const newWorkspaceService = useService(NewWorkspaceService)
const methods = [
"GET",
@@ -507,61 +506,34 @@ const cycleDownMethod = () => {
}
}
const saveRequest = async () => {
const { saveContext } = tab.value.document
const saveRequest = () => {
const saveCtx = tab.value.document.saveContext
if (!saveContext) {
if (!saveCtx) {
showSaveRequestModal.value = true
return
}
if (saveCtx.originLocation === "user-collection") {
const req = tab.value.document.request
if (saveContext.originLocation === "workspace-user-collection") {
const updatedRequest = tab.value.document.request
try {
editRESTRequest(saveCtx.folderPath, saveCtx.requestIndex, req)
if (
!newWorkspaceService.activeWorkspaceHandle.value ||
!saveContext.requestHandle
) {
return
tab.value.document.isDirty = false
platform.analytics?.logEvent({
type: "HOPP_SAVE_REQUEST",
platform: "rest",
createdNow: false,
workspaceType: "personal",
})
toast.success(`${t("request.saved")}`)
} catch (e) {
tab.value.document.saveContext = undefined
saveRequest()
}
const { requestHandle } = saveContext
const requestHandleRef = requestHandle.get()
if (!requestHandleRef.value) {
return
}
if (requestHandleRef.value.type === "invalid") {
showSaveRequestModal.value = true
return
}
const updateRequestResult = await newWorkspaceService.updateRESTRequest(
requestHandle,
updatedRequest
)
if (E.isLeft(updateRequestResult)) {
// INVALID_REQUEST_HANDLE
showSaveRequestModal.value = true
if (!tab.value.document.isDirty) {
tab.value.document.isDirty = true
}
return
}
tab.value.document.isDirty = false
tab.value.document.saveContext = {
...saveContext,
requestHandle,
}
toast.success(`${t("request.saved")}`)
} else if (saveContext.originLocation === "team-collection") {
} else if (saveCtx.originLocation === "team-collection") {
const req = tab.value.document.request
// TODO: handle error case (NOTE: overwriteRequestTeams is async)
@@ -574,7 +546,7 @@ const saveRequest = async () => {
})
runMutation(UpdateRequestDocument, {
requestID: saveContext.requestID,
requestID: saveCtx.requestID,
data: {
title: req.name,
request: JSON.stringify(req),

View File

@@ -17,7 +17,8 @@
<script setup lang="ts">
import { watch } from "vue"
import { useVModel } from "@vueuse/core"
import { cloneDeep, isEqual } from "lodash-es"
import { cloneDeep } from "lodash-es"
import { isEqualHoppRESTRequest } from "@hoppscotch/data"
import { HoppTab } from "~/services/tab"
import { HoppRESTDocument } from "~/helpers/rest/document"
@@ -31,42 +32,15 @@ const emit = defineEmits<{
const tab = useVModel(props, "modelValue", emit)
// TODO: Come up with a better dirty check
let oldRequest = cloneDeep(tab.value.document.request)
watch(
() => tab.value.document.request,
(updatedValue) => {
// Request from the collection tree
if (
tab.value.document.saveContext?.originLocation ===
"workspace-user-collection"
!tab.value.document.isDirty &&
!isEqualHoppRESTRequest(oldRequest, updatedValue)
) {
const requestHandleRef =
tab.value.document.saveContext.requestHandle?.get()
if (!requestHandleRef || requestHandleRef.value.type === "invalid") {
return
}
if (
!tab.value.document.isDirty &&
!isEqual(oldRequest, requestHandleRef?.value.data.request)
) {
tab.value.document.isDirty = true
}
if (
tab.value.document.isDirty &&
isEqual(oldRequest, requestHandleRef?.value.data.request)
) {
tab.value.document.isDirty = false
}
return
}
// Unsaved request
if (!tab.value.document.isDirty && !isEqual(oldRequest, updatedValue)) {
tab.value.document.isDirty = true
}

View File

@@ -10,8 +10,7 @@
:icon="IconFolder"
:label="`${t('tab.collections')}`"
>
<!-- <Collections /> -->
<NewCollections :platform="'rest'" />
<Collections />
</HoppSmartTab>
<HoppSmartTab
:id="'env'"
@@ -38,13 +37,12 @@
</template>
<script setup lang="ts">
import { useI18n } from "@composables/i18n"
import { ref } from "vue"
import IconClock from "~icons/lucide/clock"
import IconFolder from "~icons/lucide/folder"
import IconLayers from "~icons/lucide/layers"
import IconFolder from "~icons/lucide/folder"
import IconShare2 from "~icons/lucide/share-2"
import { ref } from "vue"
import { useI18n } from "@composables/i18n"
const t = useI18n()

View File

@@ -106,15 +106,15 @@
<script setup lang="ts">
import { ref } from "vue"
import { TippyComponent } from "vue-tippy"
import { useI18n } from "~/composables/i18n"
import { HoppRESTDocument } from "~/helpers/rest/document"
import { getMethodLabelColorClassOf } from "~/helpers/rest/labelColoring"
import { HoppTab } from "~/services/tab"
import IconCopy from "~icons/lucide/copy"
import IconFileEdit from "~icons/lucide/file-edit"
import IconShare2 from "~icons/lucide/share-2"
import { useI18n } from "~/composables/i18n"
import IconXCircle from "~icons/lucide/x-circle"
import IconXSquare from "~icons/lucide/x-square"
import IconFileEdit from "~icons/lucide/file-edit"
import IconCopy from "~icons/lucide/copy"
import IconShare2 from "~icons/lucide/share-2"
import { HoppTab } from "~/services/tab"
import { HoppRESTDocument } from "~/helpers/rest/document"
const t = useI18n()

View File

@@ -1,32 +0,0 @@
<template>
<div v-if="!activeWorkspaceHandle">No Workspace Selected.</div>
<NewCollectionsRest
v-else-if="platform === 'rest'"
:picked="picked"
:save-request="saveRequest"
:workspace-handle="activeWorkspaceHandle"
@select="(payload) => emit('select', payload)"
/>
</template>
<script setup lang="ts">
import { useService } from "dioc/vue"
import { Picked } from "~/helpers/types/HoppPicked"
import { NewWorkspaceService } from "~/services/new-workspace"
defineProps<{
picked?: Picked | null
platform: "rest" | "gql"
saveRequest?: boolean
}>()
const emit = defineEmits<{
(event: "select", payload: Picked | null): void
}>()
const workspaceService = useService(NewWorkspaceService)
const activeWorkspaceHandle = workspaceService.activeWorkspaceHandle
</script>

View File

@@ -1,450 +0,0 @@
<template>
<div class="flex flex-col">
<div
class="h-1 w-full transition"
:class="[
{
'bg-accentDark': isReorderable,
},
]"
@drop="orderUpdateCollectionEvent"
@dragover.prevent="ordering = true"
@dragleave="ordering = false"
@dragend="resetDragState"
></div>
<div class="relative flex flex-col">
<div
class="z-[1] pointer-events-none absolute inset-0 bg-accent opacity-0 transition"
:class="{
'opacity-25':
dragging && notSameDestination && notSameParentDestination,
}"
></div>
<div
class="z-[3] group pointer-events-auto relative flex cursor-pointer items-stretch"
:draggable="true"
@dragstart="dragStart"
@dragover="handleDragOver($event)"
@dragleave="resetDragState"
@dragend="
() => {
resetDragState()
dropItemID = ''
}
"
@drop="handleDrop($event)"
@contextmenu.prevent="options?.tippy?.show()"
>
<div
class="flex min-w-0 flex-1 items-center justify-center"
@click="emit('toggle-children')"
>
<span
class="pointer-events-none flex items-center justify-center px-4"
>
<component
:is="collectionIcon"
class="svg-icons"
:class="{ 'text-accent': isSelected }"
/>
</span>
<span
class="pointer-events-none flex min-w-0 flex-1 py-2 pr-2 transition group-hover:text-secondaryDark"
>
<span class="truncate" :class="{ 'text-accent': isSelected }">
{{ collectionView.name }}
</span>
</span>
</div>
<div class="flex">
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:icon="IconFilePlus"
:title="t('request.new')"
class="hidden group-hover:inline-flex"
@click="addRequest"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:icon="IconFolderPlus"
:title="t('folder.new')"
class="hidden group-hover:inline-flex"
@click="addChildCollection"
/>
<span>
<tippy
ref="options"
interactive
trigger="click"
theme="popover"
:on-shown="() => tippyActions!.focus()"
>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.more')"
:icon="IconMoreVertical"
/>
<template #content="{ hide }">
<div
ref="tippyActions"
class="flex flex-col focus:outline-none"
tabindex="0"
@keyup.r="requestAction?.$el.click()"
@keyup.n="folderAction?.$el.click()"
@keyup.e="edit?.$el.click()"
@keyup.delete="deleteAction?.$el.click()"
@keyup.x="exportAction?.$el.click()"
@keyup.p="propertiesAction?.$el.click()"
@keyup.escape="hide()"
>
<HoppSmartItem
ref="requestAction"
:icon="IconFilePlus"
:label="t('request.new')"
:shortcut="['R']"
@click="
() => {
addRequest()
hide()
}
"
/>
<HoppSmartItem
ref="folderAction"
:icon="IconFolderPlus"
:label="t('folder.new')"
:shortcut="['N']"
@click="
() => {
addChildCollection()
hide()
}
"
/>
<HoppSmartItem
ref="edit"
:icon="IconEdit"
:label="t('action.edit')"
:shortcut="['E']"
@click="
() => {
editCollection()
hide()
}
"
/>
<HoppSmartItem
ref="exportAction"
:icon="IconDownload"
:label="t('export.title')"
:shortcut="['X']"
@click="
() => {
emit('export-collection', collectionView.collectionID)
hide()
}
"
/>
<HoppSmartItem
ref="deleteAction"
:icon="IconTrash2"
:label="t('action.delete')"
:shortcut="['⌫']"
@click="
() => {
removeCollection()
hide()
}
"
/>
<HoppSmartItem
ref="propertiesAction"
:icon="IconSettings2"
:label="t('action.properties')"
:shortcut="['P']"
@click="
() => {
emit(
'edit-collection-properties',
collectionView.collectionID
)
hide()
}
"
/>
</div>
</template>
</tippy>
</span>
</div>
</div>
</div>
<div
v-if="collectionView.isLastItem"
class="w-full transition"
:class="[
{
'bg-accentDark': isLastItemReorderable,
'h-1 ': collectionView.isLastItem,
},
]"
@drop="updateLastItemOrder"
@dragover.prevent="orderingLastItem = true"
@dragleave="orderingLastItem = false"
@dragend="resetDragState"
></div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from "vue"
import { TippyComponent } from "vue-tippy"
import { useI18n } from "~/composables/i18n"
import { useReadonlyStream } from "~/composables/stream"
import {
currentReorderingStatus$,
changeCurrentReorderStatus,
} from "~/newstore/reordering"
import { RESTCollectionViewCollection } from "~/services/new-workspace/view"
import IconCheckCircle from "~icons/lucide/check-circle"
import IconDownload from "~icons/lucide/download"
import IconEdit from "~icons/lucide/edit"
import IconFilePlus from "~icons/lucide/file-plus"
import IconFolder from "~icons/lucide/folder"
import IconFolderOpen from "~icons/lucide/folder-open"
import IconFolderPlus from "~icons/lucide/folder-plus"
import IconMoreVertical from "~icons/lucide/more-vertical"
import IconSettings2 from "~icons/lucide/settings-2"
import IconTrash2 from "~icons/lucide/trash-2"
const t = useI18n()
const props = defineProps<{
collectionView: RESTCollectionViewCollection
isOpen: boolean
isSelected?: boolean | null
}>()
const emit = defineEmits<{
(event: "add-child-collection", parentCollectionIndexPath: string): void
(event: "add-request", parentCollectionIndexPath: string): void
(event: "dragging", payload: boolean): void
(event: "drop-event", payload: DataTransfer): void
(event: "drag-event", payload: DataTransfer): void
(
event: "edit-child-collection",
payload: { collectionIndexPath: string; collectionName: string }
): void
(event: "edit-collection-properties", collectionIndexPath: string): void
(
event: "edit-root-collection",
payload: { collectionIndexPath: string; collectionName: string }
): void
(event: "export-collection", collectionIndexPath: string): void
(event: "remove-child-collection", collectionIndexPath: string): void
(event: "remove-root-collection", collectionIndexPath: string): void
(event: "toggle-children"): void
(event: "update-collection-order", payload: DataTransfer): void
(event: "update-last-collection-order", payload: DataTransfer): void
}>()
const tippyActions = ref<TippyComponent | null>(null)
const requestAction = ref<HTMLButtonElement | null>(null)
const folderAction = ref<HTMLButtonElement | null>(null)
const edit = ref<HTMLButtonElement | null>(null)
const deleteAction = ref<HTMLButtonElement | null>(null)
const exportAction = ref<HTMLButtonElement | null>(null)
const propertiesAction = ref<TippyComponent | null>(null)
const options = ref<TippyComponent | null>(null)
const dragging = ref(false)
const ordering = ref(false)
const orderingLastItem = ref(false)
const dropItemID = ref("")
const currentReorderingStatus = useReadonlyStream(currentReorderingStatus$, {
type: "collection",
id: "",
parentID: "",
})
// Used to determine if the collection is being dragged to a different destination
// This is used to make the highlight effect work
watch(
() => dragging.value,
(val) => {
if (val && notSameDestination.value && notSameParentDestination.value) {
emit("dragging", true)
} else {
emit("dragging", false)
}
}
)
const collectionIcon = computed(() => {
if (props.isSelected) {
return IconCheckCircle
}
return props.isOpen ? IconFolderOpen : IconFolder
})
const notSameParentDestination = computed(() => {
return (
currentReorderingStatus.value.parentID !== props.collectionView.collectionID
)
})
const isRequestDragging = computed(() => {
return currentReorderingStatus.value.type === "request"
})
const isSameParent = computed(() => {
return (
currentReorderingStatus.value.parentID ===
props.collectionView.parentCollectionID
)
})
const isReorderable = computed(() => {
return (
ordering.value &&
notSameDestination.value &&
!isRequestDragging.value &&
isSameParent.value
)
})
const isLastItemReorderable = computed(() => {
return (
orderingLastItem.value &&
notSameDestination.value &&
!isRequestDragging.value &&
isSameParent.value
)
})
const addChildCollection = () => {
emit("add-child-collection", props.collectionView.collectionID)
}
const addRequest = () => {
emit("add-request", props.collectionView.collectionID)
}
const editCollection = () => {
const { collectionID: collectionIndexPath, name: collectionName } =
props.collectionView
const data = {
collectionIndexPath,
collectionName,
}
collectionIndexPath.split("/").length > 1
? emit("edit-child-collection", data)
: emit("edit-root-collection", data)
}
const dragStart = ({ dataTransfer }: DragEvent) => {
if (dataTransfer) {
emit("drag-event", dataTransfer)
dropItemID.value = dataTransfer.getData("collectionIndex")
dragging.value = !dragging.value
changeCurrentReorderStatus({
type: "collection",
id: props.collectionView.collectionID,
parentID: props.collectionView.parentCollectionID,
})
}
}
// Trigger the re-ordering event when a collection is dragged over another collection's top section
const handleDragOver = (e: DragEvent) => {
dragging.value = true
if (
e.offsetY < 10 &&
notSameDestination.value &&
!isRequestDragging.value &&
isSameParent.value
) {
ordering.value = true
dragging.value = false
orderingLastItem.value = false
} else if (
e.offsetY > 18 &&
notSameDestination.value &&
!isRequestDragging.value &&
isSameParent.value &&
props.collectionView.isLastItem
) {
orderingLastItem.value = true
dragging.value = false
ordering.value = false
} else {
ordering.value = false
orderingLastItem.value = false
}
}
const handleDrop = (e: DragEvent) => {
if (ordering.value) {
orderUpdateCollectionEvent(e)
} else if (orderingLastItem.value) {
updateLastItemOrder(e)
} else {
notSameParentDestination.value ? dropEvent(e) : e.stopPropagation()
}
}
const dropEvent = (e: DragEvent) => {
if (e.dataTransfer) {
e.stopPropagation()
emit("drop-event", e.dataTransfer)
resetDragState()
}
}
const orderUpdateCollectionEvent = (e: DragEvent) => {
if (e.dataTransfer) {
e.stopPropagation()
emit("update-collection-order", e.dataTransfer)
resetDragState()
}
}
const updateLastItemOrder = (e: DragEvent) => {
if (e.dataTransfer) {
e.stopPropagation()
emit("update-last-collection-order", e.dataTransfer)
resetDragState()
}
}
const notSameDestination = computed(() => {
return dropItemID.value !== props.collectionView.collectionID
})
const removeCollection = () => {
const { collectionID } = props.collectionView
collectionID.split("/").length > 1
? emit("remove-child-collection", collectionID)
: emit("remove-root-collection", collectionID)
}
const resetDragState = () => {
dragging.value = false
ordering.value = false
orderingLastItem.value = false
}
</script>

View File

@@ -1,334 +0,0 @@
<template>
<div class="flex flex-col">
<div
class="h-1 w-full transition"
:class="[
{
'bg-accentDark': isReorderable,
},
]"
@drop="updateRequestOrder"
@dragover.prevent="ordering = true"
@dragleave="resetDragState"
@dragend="resetDragState"
></div>
<div
class="group flex items-stretch"
:draggable="true"
@dragstart="dragStart"
@dragover="handleDragOver($event)"
@dragleave="resetDragState"
@dragend="resetDragState"
@drop="handleDrop"
@contextmenu.prevent="options?.tippy?.show()"
>
<div
class="pointer-events-auto flex min-w-0 flex-1 cursor-pointer items-center justify-center"
@click="selectRequest"
>
<span
class="pointer-events-none flex w-16 items-center justify-center truncate px-2"
:class="requestLabelColor"
:style="{ color: requestLabelColor }"
>
<component
:is="IconCheckCircle"
v-if="isSelected"
class="svg-icons"
:class="{ 'text-accent': isSelected }"
/>
<span v-else class="truncate text-tiny font-semibold">
{{ requestView.request.method }}
</span>
</span>
<span
class="pointer-events-none flex min-w-0 flex-1 items-center py-2 pr-2 transition group-hover:text-secondaryDark"
>
<span class="truncate" :class="{ 'text-accent': isSelected }">
{{ requestView.request.name }}
</span>
<span
v-if="props.isActive"
v-tippy="{ theme: 'tooltip' }"
class="relative mx-3 flex h-1.5 w-1.5 flex-shrink-0"
:title="`${t('collection.request_in_use')}`"
>
<span
class="absolute inline-flex h-full w-full flex-shrink-0 animate-ping rounded-full bg-green-500 opacity-75"
>
</span>
<span
class="relative inline-flex h-1.5 w-1.5 flex-shrink-0 rounded-full bg-green-500"
></span>
</span>
</span>
</div>
<div class="flex">
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:icon="IconRotateCCW"
:title="t('action.restore')"
class="hidden group-hover:inline-flex"
@click="selectRequest"
/>
<span>
<tippy
ref="options"
interactive
trigger="click"
theme="popover"
:on-shown="() => tippyActions?.focus()"
>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.more')"
:icon="IconMoreVertical"
/>
<template #content="{ hide }">
<div
ref="tippyActions"
class="flex flex-col focus:outline-none"
tabindex="0"
@keyup.e="edit?.$el.click()"
@keyup.d="duplicate?.$el.click()"
@keyup.delete="deleteAction?.$el.click()"
@keyup.s="shareAction?.$el.click()"
@keyup.escape="hide()"
>
<HoppSmartItem
ref="edit"
:icon="IconEdit"
:label="t('action.edit')"
:shortcut="['E']"
@click="
() => {
emit('edit-request', {
requestIndexPath: requestView.requestID,
requestName: requestView.request.name,
})
hide()
}
"
/>
<HoppSmartItem
ref="duplicate"
:icon="IconCopy"
:label="t('action.duplicate')"
:shortcut="['D']"
@click="
() => {
emit('duplicate-request', requestView.requestID)
hide()
}
"
/>
<HoppSmartItem
ref="deleteAction"
:icon="IconTrash2"
:label="t('action.delete')"
:shortcut="['⌫']"
@click="
() => {
emit('remove-request', requestView.requestID)
hide()
}
"
/>
<HoppSmartItem
ref="shareAction"
:icon="IconShare2"
:label="t('action.share')"
:shortcut="['S']"
@click="
() => {
emit('share-request', requestView.request)
hide()
}
"
/>
</div>
</template>
</tippy>
</span>
</div>
</div>
<div
class="w-full transition"
:class="[
{
'bg-accentDark': isLastItemReorderable,
'h-1 ': props.requestView.isLastItem,
},
]"
@drop="handleDrop"
@dragover.prevent="orderingLastItem = true"
@dragleave="resetDragState"
@dragend="resetDragState"
></div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from "@composables/i18n"
import { HoppRESTRequest } from "@hoppscotch/data"
import { computed, ref } from "vue"
import { TippyComponent } from "vue-tippy"
import { useReadonlyStream } from "~/composables/stream"
import { getMethodLabelColorClassOf } from "~/helpers/rest/labelColoring"
import {
currentReorderingStatus$,
changeCurrentReorderStatus,
} from "~/newstore/reordering"
import { RESTCollectionViewRequest } from "~/services/new-workspace/view"
import IconCheckCircle from "~icons/lucide/check-circle"
import IconCopy from "~icons/lucide/copy"
import IconEdit from "~icons/lucide/edit"
import IconMoreVertical from "~icons/lucide/more-vertical"
import IconRotateCCW from "~icons/lucide/rotate-ccw"
import IconShare2 from "~icons/lucide/share-2"
import IconTrash2 from "~icons/lucide/trash-2"
const t = useI18n()
const currentReorderingStatus = useReadonlyStream(currentReorderingStatus$, {
type: "collection",
id: "",
parentID: "",
})
const props = defineProps<{
isActive: boolean
requestView: RESTCollectionViewRequest
isSelected: boolean | null | undefined
}>()
const emit = defineEmits<{
(event: "duplicate-request", requestIndexPath: string): void
(
event: "edit-request",
payload: {
requestIndexPath: string
requestName: string
}
): void
(event: "remove-request", requestIndexPath: string): void
(event: "select-request", requestIndexPath: string): void
(event: "share-request", request: HoppRESTRequest): void
(event: "drag-request", payload: DataTransfer): void
(event: "update-request-order", payload: DataTransfer): void
(event: "update-last-request-order", payload: DataTransfer): void
}>()
const tippyActions = ref<TippyComponent | null>(null)
const edit = ref<HTMLButtonElement | null>(null)
const deleteAction = ref<HTMLButtonElement | null>(null)
const options = ref<TippyComponent | null>(null)
const duplicate = ref<HTMLButtonElement | null>(null)
const shareAction = ref<HTMLButtonElement | null>(null)
const dragging = ref(false)
const ordering = ref(false)
const orderingLastItem = ref(false)
const isCollectionDragging = computed(() => {
return currentReorderingStatus.value.type === "collection"
})
const isLastItemReorderable = computed(() => {
return (
orderingLastItem.value && isSameParent.value && !isCollectionDragging.value
)
})
const isReorderable = computed(() => {
return (
ordering.value &&
!isCollectionDragging.value &&
isSameParent.value &&
!isSameRequest.value
)
})
const isSameParent = computed(() => {
return (
currentReorderingStatus.value.parentID === props.requestView.collectionID
)
})
const isSameRequest = computed(() => {
return currentReorderingStatus.value.id === props.requestView.requestID
})
const requestLabelColor = computed(() =>
getMethodLabelColorClassOf(props.requestView.request)
)
const dragStart = ({ dataTransfer }: DragEvent) => {
if (dataTransfer) {
emit("drag-request", dataTransfer)
dragging.value = !dragging.value
changeCurrentReorderStatus({
type: "request",
id: props.requestView.requestID,
parentID: props.requestView.collectionID,
})
}
}
const handleDrop = (e: DragEvent) => {
if (ordering.value) {
updateRequestOrder(e)
} else if (orderingLastItem.value) {
updateLastItemOrder(e)
} else {
updateRequestOrder(e)
}
}
// Trigger the re-ordering event when a request is dragged over another request's top section
const handleDragOver = (e: DragEvent) => {
dragging.value = true
if (e.offsetY < 10) {
ordering.value = true
dragging.value = false
orderingLastItem.value = false
} else if (e.offsetY > 18) {
orderingLastItem.value = true
dragging.value = false
ordering.value = false
} else {
ordering.value = false
orderingLastItem.value = false
}
}
const resetDragState = () => {
dragging.value = false
ordering.value = false
orderingLastItem.value = false
}
const selectRequest = () => emit("select-request", props.requestView.requestID)
const updateRequestOrder = (e: DragEvent) => {
if (e.dataTransfer) {
e.stopPropagation()
resetDragState()
emit("update-request-order", e.dataTransfer)
}
}
const updateLastItemOrder = (e: DragEvent) => {
if (e.dataTransfer) {
e.stopPropagation()
resetDragState()
emit("update-last-request-order", e.dataTransfer)
}
}
</script>

View File

@@ -3,7 +3,7 @@
class="flex items-center overflow-x-auto whitespace-nowrap border-b border-dividerLight px-4 py-2 text-tiny text-secondaryLight"
>
<span class="truncate">
{{ workspaceName ?? t("workspace.no_workspace") }}
{{ currentWorkspace }}
</span>
<icon-lucide-chevron-right v-if="section" class="mx-2" />
{{ section }}
@@ -14,24 +14,29 @@
import { computed } from "vue"
import { useI18n } from "~/composables/i18n"
import { useService } from "dioc/vue"
import { NewWorkspaceService } from "~/services/new-workspace"
import { WorkspaceService } from "~/services/workspace.service"
defineProps<{
const props = defineProps<{
section?: string
isOnlyPersonal?: boolean
}>()
const t = useI18n()
const workspaceService = useService(NewWorkspaceService)
const activeWorkspaceHandle = workspaceService.activeWorkspaceHandle
const workspaceService = useService(WorkspaceService)
const workspace = workspaceService.currentWorkspace
const workspaceName = computed(() => {
const activeWorkspaceHandleRef = activeWorkspaceHandle.value?.get()
if (activeWorkspaceHandleRef?.value.type === "ok") {
return activeWorkspaceHandleRef.value.data.name
const currentWorkspace = computed(() => {
if (props.isOnlyPersonal || workspace.value.type === "personal") {
return t("workspace.personal")
}
return teamWorkspaceName.value
})
return undefined
const teamWorkspaceName = computed(() => {
if (workspace.value.type === "team" && workspace.value.teamName) {
return workspace.value.teamName
}
return `${t("workspace.team")}`
})
</script>

View File

@@ -1,54 +0,0 @@
<template>
<div>
<div class="flex flex-col">
<HoppSmartItem
:label="'Personal Workspace'"
:info-icon="
activeWorkspaceInfo?.provider ===
personalWorkspaceProviderService.providerID &&
activeWorkspaceInfo.workspaceID === 'personal'
? IconCheck
: undefined
"
:active-info-icon="
activeWorkspaceInfo?.provider ===
personalWorkspaceProviderService.providerID &&
activeWorkspaceInfo.workspaceID === 'personal'
"
@click="selectWorkspace"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { useService } from "dioc/vue"
import { NewWorkspaceService } from "~/services/new-workspace"
import { computed } from "vue"
import { PersonalWorkspaceProviderService } from "~/services/new-workspace/providers/personal.workspace"
import IconCheck from "~icons/lucide/check"
const workspaceService = useService(NewWorkspaceService)
const personalWorkspaceProviderService = useService(
PersonalWorkspaceProviderService
)
const activeWorkspaceInfo = computed(() => {
const activeWorkspaceHandleRef =
workspaceService.activeWorkspaceHandle.value?.get()
if (activeWorkspaceHandleRef?.value.type === "ok") {
return {
provider: activeWorkspaceHandleRef.value.data.providerID,
workspaceID: activeWorkspaceHandleRef.value.data.workspaceID,
}
}
return undefined
})
function selectWorkspace() {
workspaceService.activeWorkspaceHandle.value =
personalWorkspaceProviderService.getPersonalWorkspaceHandle()
}
</script>

View File

@@ -1,36 +1,198 @@
<template>
<div ref="rootEl">
<div class="flex flex-col">
<div
v-for="(selectorComponent, index) in workspaceSelectorComponents"
:key="index"
class="flex flex-col"
>
<component :is="selectorComponent" />
<div class="flex flex-col">
<HoppSmartItem
:label="t('workspace.personal')"
:icon="IconUser"
:info-icon="workspace.type === 'personal' ? IconDone : undefined"
:active-info-icon="workspace.type === 'personal'"
@click="switchToPersonalWorkspace"
/>
<hr />
</div>
<div v-if="loading" class="flex flex-col items-center justify-center p-4">
<HoppSmartSpinner class="mb-4" />
<span class="text-secondaryLight">{{ t("state.loading") }}</span>
</div>
<HoppSmartPlaceholder
v-if="!loading && myTeams.length === 0"
:src="`/images/states/${colorMode.value}/add_group.svg`"
:alt="`${t('empty.teams')}`"
:text="`${t('empty.teams')}`"
>
<template #body>
<HoppButtonSecondary
:label="t('team.create_new')"
filled
outline
:icon="IconPlus"
@click="displayModalAdd(true)"
/>
</template>
</HoppSmartPlaceholder>
<div v-else-if="!loading" class="flex flex-col">
<div
class="sticky top-0 z-10 mb-2 flex items-center justify-between bg-popover py-2 pl-2"
>
<div class="flex items-center px-2 font-semibold text-secondaryLight">
{{ t("workspace.other_workspaces") }}
</div>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:icon="IconPlus"
:title="`${t('team.create_new')}`"
outline
filled
class="ml-8 rounded !p-0.75"
@click="displayModalAdd(true)"
/>
</div>
<HoppSmartItem
v-for="(team, index) in myTeams"
:key="`team-${String(index)}`"
:icon="IconUsers"
:label="team.name"
:info-icon="isActiveWorkspace(team.id) ? IconDone : undefined"
:active-info-icon="isActiveWorkspace(team.id)"
@click="switchToTeamWorkspace(team)"
/>
</div>
<div
v-else-if="teamListAdapterError"
class="flex flex-col items-center py-4"
>
<icon-lucide-help-circle class="svg-icons mb-4" />
{{ t("error.something_went_wrong") }}
</div>
</div>
<TeamsAdd
:show="showModalAdd"
:switch-workspace-after-creation="true"
@hide-modal="displayModalAdd(false)"
/>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from "vue"
import { useReadonlyStream } from "~/composables/stream"
import { platform } from "~/platform"
import { useI18n } from "@composables/i18n"
import IconUser from "~icons/lucide/user"
import IconUsers from "~icons/lucide/users"
import IconPlus from "~icons/lucide/plus"
import { useColorMode } from "@composables/theming"
import { GetMyTeamsQuery } from "~/helpers/backend/graphql"
import IconDone from "~icons/lucide/check"
import { useLocalState } from "~/newstore/localstate"
import { defineActionHandler, invokeAction } from "~/helpers/actions"
import { WorkspaceService } from "~/services/workspace.service"
import { useService } from "dioc/vue"
import { NewWorkspaceService } from "~/services/new-workspace"
import { TestWorkspaceProviderService } from "~/services/new-workspace/providers/test.workspace"
import { useElementVisibility, useIntervalFn } from "@vueuse/core"
useService(TestWorkspaceProviderService)
const t = useI18n()
const colorMode = useColorMode()
const newWorkspaceService = useService(NewWorkspaceService)
const workspaceSelectorComponents =
newWorkspaceService.workspaceSelectorComponents
const showModalAdd = ref(false)
// TODO: Handle the updates to these actions
// defineActionHandler("modals.team.new", () => {
// displayModalAdd(true)
// })
//
// defineActionHandler("workspace.switch.personal", switchToPersonalWorkspace)
// defineActionHandler("workspace.switch", ({ teamId }) => {
// const team = myTeams.value.find((t) => t.id === teamId)
// if (team) switchToTeamWorkspace(team)
// })
const currentUser = useReadonlyStream(
platform.auth.getProbableUserStream(),
platform.auth.getProbableUser()
)
const workspaceService = useService(WorkspaceService)
const teamListadapter = workspaceService.acquireTeamListAdapter(null)
const myTeams = useReadonlyStream(teamListadapter.teamList$, [])
const isTeamListLoading = useReadonlyStream(teamListadapter.loading$, false)
const teamListAdapterError = useReadonlyStream(teamListadapter.error$, null)
const REMEMBERED_TEAM_ID = useLocalState("REMEMBERED_TEAM_ID")
const teamListFetched = ref(false)
const rootEl = ref<HTMLElement>()
const elVisible = useElementVisibility(rootEl)
const { pause: pauseListPoll, resume: resumeListPoll } = useIntervalFn(() => {
if (teamListadapter.isInitialized) {
teamListadapter.fetchList()
}
}, 10000)
watch(
elVisible,
() => {
if (elVisible.value) {
teamListadapter.fetchList()
resumeListPoll()
} else {
pauseListPoll()
}
},
{ immediate: true }
)
watch(myTeams, (teams) => {
if (teams && !teamListFetched.value) {
teamListFetched.value = true
if (REMEMBERED_TEAM_ID.value && currentUser.value) {
const team = teams.find((t) => t.id === REMEMBERED_TEAM_ID.value)
if (team) switchToTeamWorkspace(team)
}
}
})
const loading = computed(
() => isTeamListLoading.value && myTeams.value.length === 0
)
const workspace = workspaceService.currentWorkspace
const isActiveWorkspace = computed(() => (id: string) => {
if (workspace.value.type === "personal") return false
return workspace.value.teamID === id
})
const switchToTeamWorkspace = (team: GetMyTeamsQuery["myTeams"][number]) => {
REMEMBERED_TEAM_ID.value = team.id
workspaceService.changeWorkspace({
teamID: team.id,
teamName: team.name,
type: "team",
role: team.myRole,
})
}
const switchToPersonalWorkspace = () => {
REMEMBERED_TEAM_ID.value = undefined
workspaceService.changeWorkspace({
type: "personal",
})
}
watch(
() => currentUser.value,
(user) => {
if (!user) {
switchToPersonalWorkspace()
teamListadapter.dispose()
}
}
)
const displayModalAdd = (shouldDisplay: boolean) => {
if (!currentUser.value) return invokeAction("modals.login.toggle")
showModalAdd.value = shouldDisplay
teamListadapter.fetchList()
}
defineActionHandler("modals.team.new", () => {
displayModalAdd(true)
})
defineActionHandler("workspace.switch.personal", switchToPersonalWorkspace)
defineActionHandler("workspace.switch", ({ teamId }) => {
const team = myTeams.value.find((t) => t.id === teamId)
if (team) switchToTeamWorkspace(team)
})
</script>

View File

@@ -1,65 +0,0 @@
<template>
<div>
<div class="flex flex-col">
<HoppSmartItem
v-for="candidate in candidates"
:key="candidate.id"
:label="candidate.name"
:info-icon="
activeWorkspaceInfo?.provider ===
testWorkspaceProviderService.providerID &&
activeWorkspaceInfo.workspaceID === candidate.id
? IconCheck
: undefined
"
:active-info-icon="
activeWorkspaceInfo?.provider ===
testWorkspaceProviderService.providerID &&
activeWorkspaceInfo.workspaceID === candidate.id
"
@click="selectWorkspace(candidate.id)"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { useService } from "dioc/vue"
import { computed } from "vue"
import { NewWorkspaceService } from "~/services/new-workspace"
import { TestWorkspaceProviderService } from "~/services/new-workspace/providers/test.workspace"
import IconCheck from "~icons/lucide/check"
import * as E from "fp-ts/Either"
const workspaceService = useService(NewWorkspaceService)
const testWorkspaceProviderService = useService(TestWorkspaceProviderService)
const candidates = testWorkspaceProviderService.getWorkspaceCandidates()
const activeWorkspaceInfo = computed(() => {
const activeWorkspaceHandle = workspaceService.activeWorkspaceHandle.value
const activeWorkspaceHandleRef = activeWorkspaceHandle?.get()
if (activeWorkspaceHandleRef?.value.type === "ok") {
return {
provider: activeWorkspaceHandleRef.value.data.providerID,
workspaceID: activeWorkspaceHandleRef.value.data.workspaceID,
}
}
return undefined
})
async function selectWorkspace(workspaceID: string) {
const result =
await testWorkspaceProviderService.getWorkspaceHandle(workspaceID)
// TODO: Re-evaluate this ?
if (E.isLeft(result)) {
console.error(result)
return
}
workspaceService.activeWorkspaceHandle.value = result.right
}
</script>

View File

@@ -1,92 +0,0 @@
import { HoppCollection } from "@hoppscotch/data"
import { ChildrenResult, SmartTreeAdapter } from "@hoppscotch/ui"
import { Ref, computed, ref } from "vue"
import { navigateToFolderWithIndexPath } from "~/newstore/collections"
import { RESTCollectionViewItem } from "~/services/new-workspace/view"
export class WorkspaceRESTSearchCollectionTreeAdapter
implements SmartTreeAdapter<RESTCollectionViewItem>
{
constructor(public data: Ref<HoppCollection[]>) {}
getChildren(
nodeID: string | null
): Ref<ChildrenResult<RESTCollectionViewItem>> {
const result = ref<ChildrenResult<RESTCollectionViewItem>>({
status: "loading",
})
return computed(() => {
if (nodeID === null) {
result.value = {
status: "loaded",
data: this.data.value.map((item, index) => ({
id: index.toString(),
data: <RESTCollectionViewItem>{
type: "collection",
value: {
collectionID: index.toString(),
isLastItem: index === this.data.value.length - 1,
name: item.name,
parentCollectionID: null,
},
},
})),
}
} else {
const indexPath = nodeID.split("/").map((x) => parseInt(x))
const item = navigateToFolderWithIndexPath(this.data.value, indexPath)
if (item) {
const collections = item.folders.map(
(childCollection, childCollectionID) => {
return {
id: `${nodeID}/${childCollectionID}`,
data: <RESTCollectionViewItem>{
type: "collection",
value: {
isLastItem:
item.folders?.length > 1
? childCollectionID === item.folders.length - 1
: false,
collectionID: `${nodeID}/${childCollectionID}`,
name: childCollection.name,
parentCollectionID: nodeID,
},
},
}
}
)
const requests = item.requests.map((request, requestID) => {
return {
id: `${nodeID}/${requestID}`,
data: <RESTCollectionViewItem>{
type: "request",
value: {
isLastItem:
item.requests?.length > 1
? requestID === item.requests.length - 1
: false,
parentCollectionID: nodeID,
collectionID: nodeID,
requestID: `${nodeID}/${requestID}`,
request,
},
},
}
})
result.value = {
status: "loaded",
data: [...collections, ...requests],
}
}
}
return result.value
})
}
}

View File

@@ -1,134 +0,0 @@
import {
ChildrenResult,
SmartTreeAdapter,
} from "@hoppscotch/ui/dist/src/helpers/treeAdapter"
import * as E from "fp-ts/Either"
import { Ref, ref, watchEffect } from "vue"
import { NewWorkspaceService } from "~/services/new-workspace"
import { Handle } from "~/services/new-workspace/handle"
import { RESTCollectionViewItem } from "~/services/new-workspace/view"
import { Workspace } from "~/services/new-workspace/workspace"
export class WorkspaceRESTCollectionTreeAdapter
implements SmartTreeAdapter<RESTCollectionViewItem>
{
constructor(
private workspaceHandle: Handle<Workspace>,
private workspaceService: NewWorkspaceService
) {}
public getChildren(
nodeID: string | null,
nodeType?: string
): Ref<ChildrenResult<RESTCollectionViewItem>> {
const workspaceHandleRef = this.workspaceHandle.get()
if (workspaceHandleRef.value.type !== "ok") {
throw new Error("Cannot issue children with invalid workspace handle")
}
const result = ref<ChildrenResult<RESTCollectionViewItem>>({
status: "loading",
})
if (nodeID !== null) {
;(async () => {
if (nodeType === "request") {
result.value = {
status: "loaded",
data: [],
}
return
}
const collectionHandleResult =
await this.workspaceService.getCollectionHandle(
this.workspaceHandle,
nodeID
)
// TODO: Better error handling
if (E.isLeft(collectionHandleResult)) {
throw new Error(JSON.stringify(collectionHandleResult.left.error))
}
const collectionHandle = collectionHandleResult.right
const collectionChildrenResult =
await this.workspaceService.getRESTCollectionChildrenView(
collectionHandle
)
// TODO: Better error handling
if (E.isLeft(collectionChildrenResult)) {
throw new Error(JSON.stringify(collectionChildrenResult.left.error))
}
const collectionChildrenViewHandle =
collectionChildrenResult.right.get()
watchEffect(() => {
if (collectionChildrenViewHandle.value.type !== "ok") return
if (collectionChildrenViewHandle.value.data.loading.value) {
result.value = {
status: "loading",
}
} else {
result.value = {
status: "loaded",
data: collectionChildrenViewHandle.value.data.content.value.map(
(item) => ({
id:
item.type === "request"
? item.value.requestID
: item.value.collectionID,
data: item,
})
),
}
}
})
})()
} else {
;(async () => {
const viewResult =
await this.workspaceService.getRESTRootCollectionView(
this.workspaceHandle
)
// TODO: Better error handling
if (E.isLeft(viewResult)) {
throw new Error(JSON.stringify(viewResult.left.error))
}
const viewHandle = viewResult.right.get()
watchEffect(() => {
if (viewHandle.value.type !== "ok") return
if (viewHandle.value.data.loading.value) {
result.value = {
status: "loading",
}
} else {
result.value = {
status: "loaded",
data: viewHandle.value.data.collections.value.map((coll) => ({
id: coll.collectionID,
data: {
type: "collection",
value: coll,
},
})),
}
}
})
})()
}
return result
}
}

View File

@@ -1,15 +1,12 @@
import { HoppCollection } from "@hoppscotch/data"
import * as E from "fp-ts/Either"
import { getService } from "~/modules/dioc"
import { GQLTabService } from "~/services/tab/graphql"
import { RESTTabService } from "~/services/tab/rest"
import { runGQLQuery } from "../backend/GQLClient"
import { GetSingleRequestDocument } from "../backend/graphql"
import { HoppGQLSaveContext } from "../graphql/document"
import { HoppRESTSaveContext } from "../rest/document"
import { HoppInheritedProperty } from "../types/HoppInheritedProperties"
import { getAffectedIndexes } from "./affectedIndex"
import { GetSingleRequestDocument } from "../backend/graphql"
import { runGQLQuery } from "../backend/GQLClient"
import * as E from "fp-ts/Either"
import { getService } from "~/modules/dioc"
import { RESTTabService } from "~/services/tab/rest"
import { HoppInheritedProperty } from "../types/HoppInheritedProperties"
import { GQLTabService } from "~/services/tab/graphql"
/**
* Resolve save context on reorder
@@ -21,12 +18,15 @@ import { getAffectedIndexes } from "./affectedIndex"
* @returns
*/
export function resolveSaveContextOnCollectionReorder(payload: {
lastIndex: number
newIndex: number
folderPath: string
length?: number // better way to do this? now it could be undefined
}) {
export function resolveSaveContextOnCollectionReorder(
payload: {
lastIndex: number
newIndex: number
folderPath: string
length?: number // better way to do this? now it could be undefined
},
type: "remove" | "drop" = "remove"
) {
const { lastIndex, folderPath, length } = payload
let { newIndex } = payload
@@ -41,6 +41,12 @@ export function resolveSaveContextOnCollectionReorder(payload: {
if (newIndex === -1) {
// if (newIndex === -1) remove it from the map because it will be deleted
affectedIndexes.delete(lastIndex)
// when collection deleted opended requests from that collection be affected
if (type === "remove") {
resetSaveContextForAffectedRequests(
folderPath ? `${folderPath}/${lastIndex}` : lastIndex.toString()
)
}
}
// add folder path as prefix to the affected indexes
@@ -56,27 +62,10 @@ export function resolveSaveContextOnCollectionReorder(payload: {
const tabService = getService(RESTTabService)
const tabs = tabService.getTabsRefTo((tab) => {
if (tab.document.saveContext?.originLocation === "user-collection") {
return affectedPaths.has(tab.document.saveContext.folderPath)
}
if (
tab.document.saveContext?.originLocation !== "workspace-user-collection"
) {
return false
}
const requestHandleRef = tab.document.saveContext.requestHandle?.get()
if (!requestHandleRef || requestHandleRef.value.type === "invalid") {
return false
}
const { requestID } = requestHandleRef.value.data
const collectionID = requestID.split("/").slice(0, -1).join("/")
return affectedPaths.has(collectionID)
return (
tab.document.saveContext?.originLocation === "user-collection" &&
affectedPaths.has(tab.document.saveContext.folderPath)
)
})
for (const tab of tabs) {
@@ -86,34 +75,6 @@ export function resolveSaveContextOnCollectionReorder(payload: {
)!
tab.value.document.saveContext.folderPath = newPath
}
if (
tab.value.document.saveContext?.originLocation !==
"workspace-user-collection"
) {
return false
}
const requestHandleRef = tab.value.document.saveContext.requestHandle?.get()
if (!requestHandleRef || requestHandleRef.value.type === "invalid") {
return false
}
const { requestID } = requestHandleRef.value.data
const collectionID = requestID.split("/").slice(0, -1).join("/")
const newCollectionID = affectedPaths.get(collectionID)
const newRequestID = `${newCollectionID}/${
requestID.split("/").slice(-1)[0]
}`
requestHandleRef.value.data = {
...requestHandleRef.value.data,
collectionID: newCollectionID!,
requestID: newRequestID,
}
}
}
@@ -125,63 +86,25 @@ export function resolveSaveContextOnCollectionReorder(payload: {
*/
export function updateSaveContextForAffectedRequests(
draggedCollectionIndex: string,
destinationCollectionIndex: string
oldFolderPath: string,
newFolderPath: string
) {
const tabService = getService(RESTTabService)
const tabs = tabService.getTabsRefTo((tab) => {
return (
tab.document.saveContext?.originLocation === "user-collection" &&
tab.document.saveContext.folderPath.startsWith(oldFolderPath)
)
})
const activeTabs = tabService.getActiveTabs()
for (const tab of activeTabs.value) {
if (tab.document.saveContext?.originLocation === "user-collection") {
const { folderPath } = tab.document.saveContext
if (folderPath.startsWith(draggedCollectionIndex)) {
const newFolderPath = folderPath.replace(
draggedCollectionIndex,
destinationCollectionIndex
)
tab.document.saveContext = {
...tab.document.saveContext,
folderPath: newFolderPath,
}
}
return
}
if (
tab.document.saveContext?.originLocation === "workspace-user-collection"
) {
const requestHandleRef = tab.document.saveContext.requestHandle?.get()
if (!requestHandleRef || requestHandleRef.value.type === "invalid") {
return false
}
const { requestID } = requestHandleRef.value.data
const collectionID = requestID.split("/").slice(0, -1).join("/")
const requestIndex = requestID.split("/").slice(-1)[0]
if (collectionID.startsWith(draggedCollectionIndex)) {
const newCollectionID = collectionID.replace(
draggedCollectionIndex,
destinationCollectionIndex
)
const newRequestID = `${newCollectionID}/${requestIndex}`
tab.document.saveContext = {
...tab.document.saveContext,
requestID: newRequestID,
}
requestHandleRef.value.data = {
...requestHandleRef.value.data,
collectionID: newCollectionID,
requestID: newRequestID,
}
for (const tab of tabs) {
if (tab.value.document.saveContext?.originLocation === "user-collection") {
tab.value.document.saveContext = {
...tab.value.document.saveContext,
folderPath: tab.value.document.saveContext.folderPath.replace(
oldFolderPath,
newFolderPath
),
}
}
}
@@ -243,33 +166,6 @@ function removeDuplicatesAndKeepLast(arr: HoppInheritedProperty["headers"]) {
return result
}
function getSaveContextCollectionID(
saveContext: HoppRESTSaveContext | HoppGQLSaveContext | undefined
): string | undefined {
if (!saveContext) {
return
}
const { originLocation } = saveContext
if (originLocation === "team-collection") {
return saveContext.collectionID
}
if (originLocation === "user-collection") {
return saveContext.folderPath
}
const requestHandleRef = saveContext.requestHandle?.get()
if (!requestHandleRef || requestHandleRef.value.type === "invalid") {
return
}
// TODO: Remove `collectionID` and obtain it from `requestID`
return requestHandleRef.value.data.collectionID
}
export function updateInheritedPropertiesForAffectedRequests(
path: string,
inheritedProperties: HoppInheritedProperty,
@@ -281,17 +177,22 @@ export function updateInheritedPropertiesForAffectedRequests(
const effectedTabs = tabService.getTabsRefTo((tab) => {
const saveContext = tab.document.saveContext
const collectionID = getSaveContextCollectionID(saveContext)
return collectionID?.startsWith(path) ?? false
const saveContextPath =
saveContext?.originLocation === "team-collection"
? saveContext.collectionID
: saveContext?.folderPath
return saveContextPath?.startsWith(path) ?? false
})
effectedTabs.map((tab) => {
const inheritedParentID =
tab.value.document.inheritedProperties?.auth.parentID
const contextPath = getSaveContextCollectionID(
tab.value.document.saveContext
)
const contextPath =
tab.value.document.saveContext?.originLocation === "team-collection"
? tab.value.document.saveContext.collectionID
: tab.value.document.saveContext?.folderPath
const effectedPath = folderPathCloseToSaveContext(
inheritedParentID,
@@ -326,6 +227,21 @@ export function updateInheritedPropertiesForAffectedRequests(
})
}
function resetSaveContextForAffectedRequests(folderPath: string) {
const tabService = getService(RESTTabService)
const tabs = tabService.getTabsRefTo((tab) => {
return (
tab.document.saveContext?.originLocation === "user-collection" &&
tab.document.saveContext.folderPath.startsWith(folderPath)
)
})
for (const tab of tabs) {
tab.value.document.saveContext = null
tab.value.document.isDirty = true
}
}
/**
* Reset save context to null if requests are deleted from the team collection or its folder
* only runs when collection or folder is deleted

View File

@@ -3,10 +3,9 @@ import {
HoppGQLRequest,
HoppRESTRequest,
} from "@hoppscotch/data"
import { getService } from "~/modules/dioc"
import { RESTTabService } from "~/services/tab/rest"
import { getAffectedIndexes } from "./affectedIndex"
import { RESTTabService } from "~/services/tab/rest"
import { getService } from "~/modules/dioc"
/**
* Resolve save context on reorder
@@ -30,76 +29,30 @@ export function resolveSaveContextOnRequestReorder(payload: {
if (newIndex > lastIndex) newIndex-- // there is a issue when going down? better way to resolve this?
if (lastIndex === newIndex) return
const affectedIndices = getAffectedIndexes(
const affectedIndexes = getAffectedIndexes(
lastIndex,
newIndex === -1 ? length! : newIndex
)
// if (newIndex === -1) remove it from the map because it will be deleted
if (newIndex === -1) affectedIndices.delete(lastIndex)
if (newIndex === -1) affectedIndexes.delete(lastIndex)
const tabService = getService(RESTTabService)
const tabs = tabService.getTabsRefTo((tab) => {
if (tab.document.saveContext?.originLocation === "user-collection") {
return (
tab.document.saveContext.folderPath === folderPath &&
affectedIndices.has(tab.document.saveContext.requestIndex)
)
}
if (
tab.document.saveContext?.originLocation !== "workspace-user-collection"
) {
return false
}
const requestHandleRef = tab.document.saveContext.requestHandle?.get()
if (!requestHandleRef || requestHandleRef.value.type === "invalid") {
return false
}
const { requestID } = requestHandleRef.value.data
const collectionID = requestID.split("/").slice(0, -1).join("/")
const requestIndex = parseInt(requestID.split("/").slice(-1)[0])
return collectionID === folderPath && affectedIndices.has(requestIndex)
return (
tab.document.saveContext?.originLocation === "user-collection" &&
tab.document.saveContext.folderPath === folderPath &&
affectedIndexes.has(tab.document.saveContext.requestIndex)
)
})
for (const tab of tabs) {
if (tab.value.document.saveContext?.originLocation === "user-collection") {
const newIndex = affectedIndices.get(
const newIndex = affectedIndexes.get(
tab.value.document.saveContext?.requestIndex
)!
tab.value.document.saveContext.requestIndex = newIndex
}
if (
tab.value.document.saveContext?.originLocation !==
"workspace-user-collection"
) {
return
}
const requestHandleRef = tab.value.document.saveContext.requestHandle?.get()
if (!requestHandleRef || requestHandleRef.value.type === "invalid") {
return
}
const { requestID } = requestHandleRef.value.data
const requestIDArr = requestID.split("/")
const requestIndex = affectedIndices.get(
parseInt(requestIDArr[requestIDArr.length - 1])
)!
requestIDArr[requestIDArr.length - 1] = requestIndex.toString()
requestHandleRef.value.data.requestID = requestIDArr.join("/")
requestHandleRef.value.data.collectionID = requestIDArr
.slice(0, -1)
.join("/")
}
}
@@ -114,7 +67,7 @@ export function getRequestsByPath(
if (pathArray.length === 1) {
const latestVersionedRequests = currentCollection.requests.filter(
(req): req is HoppRESTRequest => req.v === "4"
(req): req is HoppRESTRequest => req.v === "3"
)
return latestVersionedRequests
@@ -125,7 +78,7 @@ export function getRequestsByPath(
}
const latestVersionedRequests = currentCollection.requests.filter(
(req): req is HoppRESTRequest => req.v === "4"
(req): req is HoppRESTRequest => req.v === "3"
)
return latestVersionedRequests

View File

@@ -1,31 +1,32 @@
import * as E from "fp-ts/Either"
import { platform } from "~/platform"
/**
* Create a downloadable file from a collection and prompts the user to download it.
* @param collectionJSON - JSON string of the collection
* @param name - Name of the collection set as the file name
*/
export const initializeDownloadFile = async (
export const initializeDownloadCollection = (
collectionJSON: string,
name: string | null
) => {
const result = await platform.io.saveFileWithDialog({
data: collectionJSON,
contentType: "application/json",
suggestedFilename: `${name ?? "collection"}.json`,
filters: [
{
name: "Hoppscotch Collection/Environment JSON file",
extensions: ["json"],
},
],
})
const file = new Blob([collectionJSON], { type: "application/json" })
const a = document.createElement("a")
const url = URL.createObjectURL(file)
a.href = url
if (result.type === "unknown" || result.type === "saved") {
return E.right("state.download_started")
if (name) {
a.download = `${name}.json`
} else {
a.download = `${url.split("/").pop()!.split("#")[0].split("?")[0]}.json`
}
return E.left("state.download_failed")
document.body.appendChild(a)
a.click()
setTimeout(() => {
document.body.removeChild(a)
URL.revokeObjectURL(url)
}, 1000)
return E.right("state.download_started")
}

View File

@@ -0,0 +1,5 @@
import { HoppCollection } from "@hoppscotch/data"
export const myCollectionsExporter = (myCollections: HoppCollection[]) => {
return JSON.stringify(myCollections, null, 2)
}

View File

@@ -3,37 +3,8 @@ import { HoppRESTResponse } from "../types/HoppRESTResponse"
import { HoppTestResult } from "../types/HoppTestResult"
import { RESTOptionTabs } from "~/components/http/RequestOptions.vue"
import { HoppInheritedProperty } from "../types/HoppInheritedProperties"
import { Handle } from "~/services/new-workspace/handle"
import { WorkspaceRequest } from "~/services/new-workspace/workspace"
export type HoppRESTSaveContext =
| {
/**
* The origin source of the request
*/
// TODO: Make this `user-collection` after porting all usages
// Future TODO: Keep separate types for the IDs (specific to persistence) & `requestHandle` (only existing at runtime)
originLocation: "workspace-user-collection"
/**
* ID of the workspace
*/
workspaceID?: string
/**
* ID of the provider
*/
providerID?: string
/**
* Path to the request in the collection tree
*/
requestID?: string
/**
* Handle to the request open in the tab
*/
requestHandle?: Handle<WorkspaceRequest>
}
| {
/**
* The origin source of the request

View File

@@ -17,24 +17,3 @@ export type HoppInheritedProperty = {
inheritedHeader: HoppRESTHeader | GQLHeader
}[]
}
type ModifiedAuth<T, AuthType> = {
[K in keyof T]: K extends "inheritedAuth" ? Extract<T[K], AuthType> : T[K]
}
type ModifiedHeaders<T, HeaderType> = {
[K in keyof T]: K extends "inheritedHeader" ? Extract<T[K], HeaderType> : T[K]
}
type ModifiedHoppInheritedProperty<AuthType, HeaderType> = {
auth: ModifiedAuth<HoppInheritedProperty["auth"], AuthType>
headers: ModifiedHeaders<
HoppInheritedProperty["headers"][number],
HeaderType
>[]
}
export type HoppInheritedRESTProperty = ModifiedHoppInheritedProperty<
HoppRESTAuth,
HoppRESTHeader
>

View File

@@ -20,29 +20,28 @@ export type Picked =
pickedType: "my-collection"
collectionIndex: number
}
// TODO: Enable this when rest of the implementation is in place
// | {
// pickedType: "teams-request"
// requestID: string
// }
// | {
// pickedType: "teams-folder"
// folderID: string
// }
// | {
// pickedType: "teams-collection"
// collectionID: string
// }
// | {
// pickedType: "gql-my-request"
// folderPath: string
// requestIndex: number
// }
// | {
// pickedType: "gql-my-folder"
// folderPath: string
// }
// | {
// pickedType: "gql-my-collection"
// collectionIndex: number
// }
| {
pickedType: "teams-request"
requestID: string
}
| {
pickedType: "teams-folder"
folderID: string
}
| {
pickedType: "teams-collection"
collectionID: string
}
| {
pickedType: "gql-my-request"
folderPath: string
requestIndex: number
}
| {
pickedType: "gql-my-folder"
folderPath: string
}
| {
pickedType: "gql-my-collection"
collectionIndex: number
}

View File

@@ -1,17 +0,0 @@
/**
* Create a function that will only run the given function once and caches the result.
*/
export function lazy<T>(fn: () => T): () => T {
let funcRan = false
let result: T | null = null
return () => {
if (!funcRan) {
result = fn()
funcRan = true
return result
}
return result!
}
}

View File

@@ -3,40 +3,36 @@ import { createApp } from "vue"
import { initializeApp } from "./helpers/app"
import { initBackendGQLClient } from "./helpers/backend/GQLClient"
import { performMigrations } from "./helpers/migrations"
import { getService } from "./modules/dioc"
import { PlatformDef, setPlatformDef } from "./platform"
import { PersonalWorkspaceProviderService } from "./services/new-workspace/providers/personal.workspace"
import { TestWorkspaceProviderService } from "./services/new-workspace/providers/test.workspace"
import { PersistenceService } from "./services/persistence"
import "nprogress/nprogress.css"
import "../assets/scss/styles.scss"
import "../assets/scss/tailwind.scss"
import "../assets/themes/themes.scss"
import "../assets/scss/styles.scss"
import "nprogress/nprogress.css"
import "unfonts.css"
import App from "./App.vue"
import { getService } from "./modules/dioc"
import { PersistenceService } from "./services/persistence"
export function createHoppApp(el: string | Element, platformDef: PlatformDef) {
setPlatformDef(platformDef)
const app = createApp(App)
HOPP_MODULES.forEach((mod) => mod.onVueAppInit?.(app))
platformDef.addedHoppModules?.forEach((mod) => mod.onVueAppInit?.(app))
// Some basic work that needs to be done before module inits even
initBackendGQLClient()
initializeApp()
HOPP_MODULES.forEach((mod) => mod.onVueAppInit?.(app))
platformDef.addedHoppModules?.forEach((mod) => mod.onVueAppInit?.(app))
// TODO: Explore possibilities of moving this invocation to the service constructor
// `toast` was coming up as `null` in the previous attempts
getService(PersistenceService).setupLocalPersistence()
performMigrations()
// TODO: Remove this
getService(TestWorkspaceProviderService)
getService(PersonalWorkspaceProviderService)
app.mount(el)
console.info(

View File

@@ -319,7 +319,6 @@ const restCollectionDispatchers = defineDispatchers({
)
return {}
}
// We get the index path to the folder itself,
// we have to find the folder containing the target folder,
// so we pop the last path index
@@ -691,11 +690,17 @@ const restCollectionDispatchers = defineDispatchers({
// if the destination is null, we are moving to the end of the list
if (destinationRequestIndex === null) {
// TODO: Verify if this can be safely removed
// move to the end of the list
targetLocation.requests.push(
targetLocation.requests.splice(requestIndex, 1)[0]
)
resolveSaveContextOnRequestReorder({
lastIndex: requestIndex,
newIndex: targetLocation.requests.length,
folderPath: destinationCollectionPath,
})
return {
state: newState,
}
@@ -703,6 +708,12 @@ const restCollectionDispatchers = defineDispatchers({
reorderItems(targetLocation.requests, requestIndex, destinationRequestIndex)
resolveSaveContextOnRequestReorder({
lastIndex: requestIndex,
newIndex: destinationRequestIndex,
folderPath: destinationCollectionPath,
})
return {
state: newState,
}

View File

@@ -31,7 +31,7 @@
</template>
<template #suffix>
<span
v-if="getTabDirtyStatus(tab)"
v-if="tab.document.isDirty"
class="flex w-4 items-center justify-center text-secondary group-hover:hidden"
>
<svg
@@ -64,13 +64,6 @@
@submit="renameReqName"
@hide-modal="showRenamingReqNameModal = false"
/>
<HoppSmartConfirmModal
:show="confirmingCloseForTabID !== null"
:confirm="t('modal.close_unsaved_tab')"
:title="t('confirm.save_unsaved_tab')"
@hide-modal="onCloseConfirmSaveTab"
@resolve="onResolveConfirmSaveTab"
/>
<HoppSmartConfirmModal
:show="confirmingCloseAllTabs"
:confirm="t('modal.close_unsaved_tab')"
@@ -78,6 +71,36 @@
@hide-modal="confirmingCloseAllTabs = false"
@resolve="onResolveConfirmCloseAllTabs"
/>
<HoppSmartModal
v-if="confirmingCloseForTabID !== null"
dialog
role="dialog"
aria-modal="true"
:title="t('modal.close_unsaved_tab')"
@close="confirmingCloseForTabID = null"
>
<template #body>
<div class="text-center">
{{ t("confirm.save_unsaved_tab") }}
</div>
</template>
<template #footer>
<span class="flex space-x-2">
<HoppButtonPrimary
v-focus
:label="t?.('action.yes')"
outline
@click="onResolveConfirmSaveTab"
/>
<HoppButtonSecondary
:label="t?.('action.no')"
filled
outline
@click="onCloseConfirmSaveTab"
/>
</span>
</template>
</HoppSmartModal>
<CollectionsSaveRequest
v-if="savingRequest"
mode="rest"
@@ -95,24 +118,24 @@
</template>
<script lang="ts" setup>
import { useI18n } from "@composables/i18n"
import { ref, onMounted } from "vue"
import { safelyExtractRESTRequest } from "@hoppscotch/data"
import { useService } from "dioc/vue"
import { cloneDeep } from "lodash-es"
import { onMounted, ref } from "vue"
import { useRoute } from "vue-router"
import { useReadonlyStream } from "~/composables/stream"
import { translateExtURLParams } from "~/helpers/RESTExtURLParams"
import { defineActionHandler, invokeAction } from "~/helpers/actions"
import { useRoute } from "vue-router"
import { useI18n } from "@composables/i18n"
import { getDefaultRESTRequest } from "~/helpers/rest/default"
import { HoppRESTDocument } from "~/helpers/rest/document"
import { defineActionHandler, invokeAction } from "~/helpers/actions"
import { platform } from "~/platform"
import { useReadonlyStream } from "~/composables/stream"
import { useService } from "dioc/vue"
import { InspectionService } from "~/services/inspection"
import { EnvironmentInspectorService } from "~/services/inspection/inspectors/environment.inspector"
import { HeaderInspectorService } from "~/services/inspection/inspectors/header.inspector"
import { EnvironmentInspectorService } from "~/services/inspection/inspectors/environment.inspector"
import { ResponseInspectorService } from "~/services/inspection/inspectors/response.inspector"
import { HoppTab } from "~/services/tab"
import { cloneDeep } from "lodash-es"
import { RESTTabService } from "~/services/tab/rest"
import { HoppTab } from "~/services/tab"
import { HoppRESTDocument } from "~/helpers/rest/document"
const savingRequest = ref(false)
const confirmingCloseForTabID = ref<string | null>(null)
@@ -193,7 +216,7 @@ const inspectionService = useService(InspectionService)
const removeTab = (tabID: string) => {
const tabState = tabs.getTabRef(tabID).value
if (getTabDirtyStatus(tabState)) {
if (tabState.document.isDirty) {
confirmingCloseForTabID.value = tabID
} else {
tabs.closeTab(tabState.id)
@@ -202,10 +225,8 @@ const removeTab = (tabID: string) => {
}
const closeOtherTabsAction = (tabID: string) => {
const isTabDirty = tabs.getTabRef(tabID).value?.document.isDirty
const dirtyTabCount = tabs.getDirtyTabsCount()
const isTabDirty = getTabDirtyStatus(tabs.getTabRef(tabID).value)
// If current tab is dirty, so we need to subtract 1 from the dirty tab count
const balanceDirtyTabCount = isTabDirty ? dirtyTabCount - 1 : dirtyTabCount
@@ -270,24 +291,15 @@ const onCloseConfirmSaveTab = () => {
* Called when the user confirms they want to save the tab
*/
const onResolveConfirmSaveTab = () => {
const { saveContext } = tabs.currentActiveTab.value.document
if (tabs.currentActiveTab.value.document.saveContext) {
invokeAction("request.save")
// There're two cases where the save request under a collection modal should open
// 1. Attempting to save a request that is not under a collection (When the save context is not available)
// 2. Deleting a request from the collection tree and attempting to save it while closing the respective tab (When the request handle is invalid)
if (
!saveContext ||
(saveContext.originLocation === "workspace-user-collection" &&
saveContext.requestHandle?.get().value.type === "invalid")
) {
return (savingRequest.value = true)
}
invokeAction("request.save")
if (confirmingCloseForTabID.value) {
tabs.closeTab(confirmingCloseForTabID.value)
confirmingCloseForTabID.value = null
if (confirmingCloseForTabID.value) {
tabs.closeTab(confirmingCloseForTabID.value)
confirmingCloseForTabID.value = null
}
} else {
savingRequest.value = true
}
}
@@ -315,17 +327,6 @@ const shareTabRequest = (tabID: string) => {
}
}
const getTabDirtyStatus = (tab: HoppTab<HoppRESTDocument>) => {
if (tab.document.isDirty) {
return true
}
return (
tab.document.saveContext?.originLocation === "workspace-user-collection" &&
tab.document.saveContext.requestHandle?.get().value.type === "invalid"
)
}
defineActionHandler("contextmenu.open", ({ position, text }) => {
if (text) {
contextMenu.value = {

View File

@@ -13,17 +13,15 @@ export const browserIODef: IOPlatformDef = {
const url = URL.createObjectURL(file)
a.href = url
a.download =
opts.suggestedFilename ??
pipe(
url,
S.split("/"),
RNEA.last,
S.split("#"),
RNEA.head,
S.split("?"),
RNEA.head
)
a.download = pipe(
url,
S.split("/"),
RNEA.last,
S.split("#"),
RNEA.head,
S.split("?"),
RNEA.head
)
document.body.appendChild(a)
a.click()

View File

@@ -121,11 +121,6 @@ export class InspectionService extends Service {
}
private initializeListeners() {
console.log(
`Current active tab from inspection service is `,
this.restTab.currentActiveTab.value
)
watch(
() => [this.inspectors.entries(), this.restTab.currentActiveTab.value.id],
() => {

View File

@@ -1,18 +0,0 @@
import { Ref, WritableComputedRef } from "vue"
export type Handle<T, InvalidateReason = unknown> = {
get: () => HandleRef<T, InvalidateReason>
}
export type HandleRef<T, InvalidateReason = unknown> = Ref<
HandleState<T, InvalidateReason>
>
export type HandleState<T, InvalidateReason> =
| { type: "ok"; data: T }
| { type: "invalid"; reason: InvalidateReason }
export type WritableHandleRef<
T,
InvalidateReason = unknown,
> = WritableComputedRef<HandleState<T, InvalidateReason>>

View File

@@ -1,48 +0,0 @@
import { Ref } from "vue"
import { HandleRef } from "./handle"
import { Workspace, WorkspaceCollection, WorkspaceRequest } from "./workspace"
export const isValidWorkspaceHandle = (
workspaceHandle: HandleRef<Workspace>,
providerID: string,
workspaceID: string
): workspaceHandle is Ref<{
data: Workspace
type: "ok"
}> => {
return (
workspaceHandle.value.type === "ok" &&
workspaceHandle.value.data.providerID === providerID &&
workspaceHandle.value.data.workspaceID === workspaceID
)
}
export const isValidCollectionHandle = (
collectionHandle: HandleRef<WorkspaceCollection>,
providerID: string,
workspaceID: string
): collectionHandle is Ref<{
data: WorkspaceCollection
type: "ok"
}> => {
return (
collectionHandle.value.type === "ok" &&
collectionHandle.value.data.providerID === providerID &&
collectionHandle.value.data.workspaceID === workspaceID
)
}
export const isValidRequestHandle = (
requestHandle: HandleRef<WorkspaceRequest>,
providerID: string,
workspaceID: string
): requestHandle is Ref<{
data: WorkspaceRequest
type: "ok"
}> => {
return (
requestHandle.value.type === "ok" &&
requestHandle.value.data.providerID === providerID &&
requestHandle.value.data.workspaceID === workspaceID
)
}

View File

@@ -1,807 +0,0 @@
import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data"
import { Service } from "dioc"
import * as E from "fp-ts/Either"
import {
Component,
Ref,
computed,
markRaw,
shallowReactive,
shallowRef,
watch,
} from "vue"
import { Handle } from "./handle"
import { WorkspaceProvider } from "./provider"
import {
RESTCollectionChildrenView,
RESTCollectionJSONView,
RESTCollectionLevelAuthHeadersView,
RESTSearchResultsView,
RootRESTCollectionView,
} from "./view"
import { Workspace, WorkspaceCollection, WorkspaceRequest } from "./workspace"
export type WorkspaceError<ServiceErr> =
| { type: "SERVICE_ERROR"; error: ServiceErr }
| { type: "PROVIDER_ERROR"; error: unknown }
export class NewWorkspaceService extends Service {
public static readonly ID = "NEW_WORKSPACE_SERVICE"
private registeredProviders = shallowReactive(
new Map<string, WorkspaceProvider>()
)
public activeWorkspaceHandle: Ref<Handle<Workspace> | undefined> =
shallowRef()
public activeWorkspaceDecor = computed(() => {
const activeWorkspaceHandleRef = this.activeWorkspaceHandle.value?.get()
if (activeWorkspaceHandleRef?.value.type !== "ok") {
return undefined
}
return this.registeredProviders.get(
activeWorkspaceHandleRef.value.data.providerID
)!.workspaceDecor
})
public workspaceSelectorComponents = computed(() => {
const items: Component[] = []
const sortedProviders = Array.from(this.registeredProviders.values()).sort(
(a, b) =>
(b.workspaceDecor?.value.workspaceSelectorPriority ?? 0) -
(a.workspaceDecor?.value.workspaceSelectorPriority ?? 0)
)
for (const workspace of sortedProviders) {
if (workspace.workspaceDecor?.value?.workspaceSelectorComponent) {
items.push(workspace.workspaceDecor.value.workspaceSelectorComponent)
}
}
return items
})
override onServiceInit() {
// Watch for situations where the handle is invalidated
// so the active workspace handle definition can be invalidated
watch(
() => {
return this.activeWorkspaceHandle.value
? [
this.activeWorkspaceHandle.value,
this.activeWorkspaceHandle.value?.get(),
]
: [this.activeWorkspaceHandle.value]
},
() => {
if (!this.activeWorkspaceHandle.value) return
const activeWorkspaceHandleRef = this.activeWorkspaceHandle.value.get()
if (activeWorkspaceHandleRef.value.type === "invalid") {
this.activeWorkspaceHandle.value = undefined
}
},
{ deep: true }
)
}
public async getWorkspaceHandle(
providerID: string,
workspaceID: string
): Promise<E.Either<WorkspaceError<"INVALID_PROVIDER">, Handle<Workspace>>> {
const provider = this.registeredProviders.get(providerID)
if (!provider) {
return Promise.resolve(
E.left({ type: "SERVICE_ERROR", error: "INVALID_PROVIDER" as const })
)
}
const handleResult = await provider.getWorkspaceHandle(workspaceID)
if (E.isLeft(handleResult)) {
return E.left({ type: "PROVIDER_ERROR", error: handleResult.left })
}
return E.right(handleResult.right)
}
public async getCollectionHandle(
workspaceHandle: Handle<Workspace>,
collectionID: string
): Promise<
E.Either<
WorkspaceError<"INVALID_HANDLE" | "INVALID_PROVIDER">,
Handle<WorkspaceCollection>
>
> {
const workspaceHandleRef = workspaceHandle.get()
if (workspaceHandleRef.value.type === "invalid") {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_HANDLE" })
}
const provider = this.registeredProviders.get(
workspaceHandleRef.value.data.providerID
)
if (!provider) {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_PROVIDER" })
}
const result = await provider.getCollectionHandle(
workspaceHandle,
collectionID
)
if (E.isLeft(result)) {
return E.left({ type: "PROVIDER_ERROR", error: result.left })
}
return E.right(result.right)
}
public async getRequestHandle(
workspaceHandle: Handle<Workspace>,
requestID: string
): Promise<
E.Either<
WorkspaceError<"INVALID_HANDLE" | "INVALID_PROVIDER">,
Handle<WorkspaceRequest>
>
> {
const workspaceHandleRef = workspaceHandle.get()
if (workspaceHandleRef.value.type === "invalid") {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_HANDLE" })
}
const provider = this.registeredProviders.get(
workspaceHandleRef.value.data.providerID
)
if (!provider) {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_PROVIDER" })
}
const result = await provider.getRequestHandle(workspaceHandle, requestID)
if (E.isLeft(result)) {
return E.left({ type: "PROVIDER_ERROR", error: result.left })
}
return E.right(result.right)
}
public async createRESTRootCollection(
workspaceHandle: Handle<Workspace>,
newCollection: Partial<Exclude<HoppCollection, "id">> & { name: string }
): Promise<
E.Either<
WorkspaceError<"INVALID_HANDLE" | "INVALID_PROVIDER">,
Handle<WorkspaceCollection>
>
> {
const workspaceHandleRef = workspaceHandle.get()
if (workspaceHandleRef.value.type === "invalid") {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_HANDLE" })
}
const provider = this.registeredProviders.get(
workspaceHandleRef.value.data.providerID
)
if (!provider) {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_PROVIDER" })
}
const result = await provider.createRESTRootCollection(
workspaceHandle,
newCollection
)
if (E.isLeft(result)) {
return E.left({ type: "PROVIDER_ERROR", error: result.left })
}
return E.right(result.right)
}
public async createRESTChildCollection(
parentCollectionHandle: Handle<WorkspaceCollection>,
newChildCollection: Partial<HoppCollection> & { name: string }
): Promise<
E.Either<
WorkspaceError<"INVALID_HANDLE" | "INVALID_PROVIDER">,
Handle<WorkspaceCollection>
>
> {
const parentCollectionHandleRef = parentCollectionHandle.get()
if (parentCollectionHandleRef.value.type === "invalid") {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_HANDLE" })
}
const provider = this.registeredProviders.get(
parentCollectionHandleRef.value.data.providerID
)
if (!provider) {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_PROVIDER" })
}
const result = await provider.createRESTChildCollection(
parentCollectionHandle,
newChildCollection
)
if (E.isLeft(result)) {
return E.left({ type: "PROVIDER_ERROR", error: result.left })
}
return E.right(result.right)
}
public async updateRESTCollection(
collectionHandle: Handle<WorkspaceCollection>,
updatedCollection: Partial<HoppCollection>
): Promise<
E.Either<WorkspaceError<"INVALID_HANDLE" | "INVALID_PROVIDER">, void>
> {
const collectionHandleRef = collectionHandle.get()
if (collectionHandleRef.value.type === "invalid") {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_HANDLE" })
}
const provider = this.registeredProviders.get(
collectionHandleRef.value.data.providerID
)
if (!provider) {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_PROVIDER" })
}
const result = await provider.updateRESTCollection(
collectionHandle,
updatedCollection
)
if (E.isLeft(result)) {
return E.left({ type: "PROVIDER_ERROR", error: result.left })
}
return E.right(undefined)
}
public async removeRESTCollection(
collectionHandle: Handle<WorkspaceCollection>
): Promise<
E.Either<WorkspaceError<"INVALID_HANDLE" | "INVALID_PROVIDER">, void>
> {
const collectionHandleRef = collectionHandle.get()
if (collectionHandleRef.value.type === "invalid") {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_HANDLE" })
}
const provider = this.registeredProviders.get(
collectionHandleRef.value.data.providerID
)
if (!provider) {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_PROVIDER" })
}
const result = await provider.removeRESTCollection(collectionHandle)
if (E.isLeft(result)) {
return E.left({ type: "PROVIDER_ERROR", error: result.left })
}
return E.right(undefined)
}
public async createRESTRequest(
parentCollectionHandle: Handle<WorkspaceCollection>,
newRequest: HoppRESTRequest
): Promise<
E.Either<
WorkspaceError<"INVALID_HANDLE" | "INVALID_PROVIDER">,
Handle<WorkspaceRequest>
>
> {
const parentCollectionHandleRef = parentCollectionHandle.get()
if (parentCollectionHandleRef.value.type === "invalid") {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_HANDLE" })
}
const provider = this.registeredProviders.get(
parentCollectionHandleRef.value.data.providerID
)
if (!provider) {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_PROVIDER" })
}
const result = await provider.createRESTRequest(
parentCollectionHandle,
newRequest
)
if (E.isLeft(result)) {
return E.left({ type: "PROVIDER_ERROR", error: result.left })
}
return E.right(result.right)
}
public async removeRESTRequest(
requestHandle: Handle<WorkspaceRequest>
): Promise<
E.Either<WorkspaceError<"INVALID_HANDLE" | "INVALID_PROVIDER">, void>
> {
const requestHandleRef = requestHandle.get()
if (requestHandleRef.value.type === "invalid") {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_HANDLE" })
}
const provider = this.registeredProviders.get(
requestHandleRef.value.data.providerID
)
if (!provider) {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_PROVIDER" })
}
const result = await provider.removeRESTRequest(requestHandle)
if (E.isLeft(result)) {
return E.left({ type: "PROVIDER_ERROR", error: result.left })
}
return E.right(undefined)
}
public async updateRESTRequest(
requestHandle: Handle<WorkspaceRequest>,
updatedRequest: Partial<HoppRESTRequest>
): Promise<
E.Either<WorkspaceError<"INVALID_HANDLE" | "INVALID_PROVIDER">, void>
> {
const requestHandleRef = requestHandle.get()
if (requestHandleRef.value.type === "invalid") {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_HANDLE" })
}
const provider = this.registeredProviders.get(
requestHandleRef.value.data.providerID
)
if (!provider) {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_PROVIDER" })
}
const result = await provider.updateRESTRequest(
requestHandle,
updatedRequest
)
if (E.isLeft(result)) {
return E.left({ type: "PROVIDER_ERROR", error: result.left })
}
return E.right(result.right)
}
public async importRESTCollections(
workspaceHandle: Handle<Workspace>,
collections: HoppCollection[]
): Promise<
E.Either<
WorkspaceError<"INVALID_HANDLE" | "INVALID_PROVIDER">,
Handle<WorkspaceCollection>
>
> {
const workspaceHandleRef = workspaceHandle.get()
if (workspaceHandleRef.value.type === "invalid") {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_HANDLE" })
}
const provider = this.registeredProviders.get(
workspaceHandleRef.value.data.providerID
)
if (!provider) {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_PROVIDER" })
}
const result = await provider.importRESTCollections(
workspaceHandle,
collections
)
if (E.isLeft(result)) {
return E.left({ type: "PROVIDER_ERROR", error: result.left })
}
return E.right(result.right)
}
public async exportRESTCollections(
workspaceHandle: Handle<Workspace>,
collections: HoppCollection[]
): Promise<
E.Either<WorkspaceError<"INVALID_HANDLE" | "INVALID_PROVIDER">, void>
> {
const workspaceHandleRef = workspaceHandle.get()
if (workspaceHandleRef.value.type === "invalid") {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_HANDLE" })
}
const provider = this.registeredProviders.get(
workspaceHandleRef.value.data.providerID
)
if (!provider) {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_PROVIDER" })
}
const result = await provider.exportRESTCollections(
workspaceHandle,
collections
)
if (E.isLeft(result)) {
return E.left({ type: "PROVIDER_ERROR", error: result.left })
}
return E.right(result.right)
}
public async exportRESTCollection(
collectionHandle: Handle<WorkspaceCollection>,
collection: HoppCollection
): Promise<
E.Either<WorkspaceError<"INVALID_HANDLE" | "INVALID_PROVIDER">, void>
> {
const collectionHandleRef = collectionHandle.get()
if (collectionHandleRef.value.type === "invalid") {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_HANDLE" })
}
const provider = this.registeredProviders.get(
collectionHandleRef.value.data.providerID
)
if (!provider) {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_PROVIDER" })
}
const result = await provider.exportRESTCollection(
collectionHandle,
collection
)
if (E.isLeft(result)) {
return E.left({ type: "PROVIDER_ERROR", error: result.left })
}
return E.right(result.right)
}
public async reorderRESTCollection(
collectionHandle: Handle<WorkspaceCollection>,
destinationCollectionID: string | null
): Promise<
E.Either<WorkspaceError<"INVALID_HANDLE" | "INVALID_PROVIDER">, void>
> {
const collectionHandleRef = collectionHandle.get()
if (collectionHandleRef.value.type === "invalid") {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_HANDLE" })
}
const provider = this.registeredProviders.get(
collectionHandleRef.value.data.providerID
)
if (!provider) {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_PROVIDER" })
}
const result = await provider.reorderRESTCollection(
collectionHandle,
destinationCollectionID
)
if (E.isLeft(result)) {
return E.left({ type: "PROVIDER_ERROR", error: result.left })
}
return E.right(result.right)
}
public async moveRESTCollection(
collectionHandle: Handle<WorkspaceCollection>,
destinationCollectionID: string | null
): Promise<
E.Either<WorkspaceError<"INVALID_HANDLE" | "INVALID_PROVIDER">, void>
> {
const collectionHandleRef = collectionHandle.get()
if (collectionHandleRef.value.type === "invalid") {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_HANDLE" })
}
const provider = this.registeredProviders.get(
collectionHandleRef.value.data.providerID
)
if (!provider) {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_PROVIDER" })
}
const result = await provider.moveRESTCollection(
collectionHandle,
destinationCollectionID
)
if (E.isLeft(result)) {
return E.left({ type: "PROVIDER_ERROR", error: result.left })
}
return E.right(result.right)
}
public async reorderRESTRequest(
requestHandle: Handle<WorkspaceRequest>,
destinationCollectionID: string,
destinationRequestID: string | null
): Promise<
E.Either<WorkspaceError<"INVALID_HANDLE" | "INVALID_PROVIDER">, void>
> {
const requestHandleRef = requestHandle.get()
if (requestHandleRef.value.type === "invalid") {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_HANDLE" })
}
const provider = this.registeredProviders.get(
requestHandleRef.value.data.providerID
)
if (!provider) {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_PROVIDER" })
}
const result = await provider.reorderRESTRequest(
requestHandle,
destinationCollectionID,
destinationRequestID
)
if (E.isLeft(result)) {
return E.left({ type: "PROVIDER_ERROR", error: result.left })
}
return E.right(result.right)
}
public async moveRESTRequest(
requestHandle: Handle<WorkspaceRequest>,
destinationCollectionID: string
): Promise<
E.Either<WorkspaceError<"INVALID_HANDLE" | "INVALID_PROVIDER">, void>
> {
const requestHandleRef = requestHandle.get()
if (requestHandleRef.value.type === "invalid") {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_HANDLE" })
}
const provider = this.registeredProviders.get(
requestHandleRef.value.data.providerID
)
if (!provider) {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_PROVIDER" })
}
const result = await provider.moveRESTRequest(
requestHandle,
destinationCollectionID
)
if (E.isLeft(result)) {
return E.left({ type: "PROVIDER_ERROR", error: result.left })
}
return E.right(result.right)
}
public async getRESTCollectionChildrenView(
collectionHandle: Handle<WorkspaceCollection>
): Promise<
E.Either<
WorkspaceError<"INVALID_HANDLE" | "INVALID_PROVIDER">,
Handle<RESTCollectionChildrenView>
>
> {
const collectionHandleRef = collectionHandle.get()
if (collectionHandleRef.value.type === "invalid") {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_HANDLE" })
}
const provider = this.registeredProviders.get(
collectionHandleRef.value.data.providerID
)
if (!provider) {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_PROVIDER" })
}
const result =
await provider.getRESTCollectionChildrenView(collectionHandle)
if (E.isLeft(result)) {
return E.left({ type: "PROVIDER_ERROR", error: result.left })
}
return E.right(result.right)
}
public async getRESTRootCollectionView(
workspaceHandle: Handle<Workspace>
): Promise<
E.Either<
WorkspaceError<"INVALID_HANDLE" | "INVALID_PROVIDER">,
Handle<RootRESTCollectionView>
>
> {
const workspaceHandleRef = workspaceHandle.get()
if (workspaceHandleRef.value.type === "invalid") {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_HANDLE" })
}
const provider = this.registeredProviders.get(
workspaceHandleRef.value.data.providerID
)
if (!provider) {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_PROVIDER" })
}
const result = await provider.getRESTRootCollectionView(workspaceHandle)
if (E.isLeft(result)) {
return E.left({ type: "PROVIDER_ERROR", error: result.left })
}
return E.right(result.right)
}
public async getRESTCollectionLevelAuthHeadersView(
collectionHandle: Handle<WorkspaceCollection>
): Promise<
E.Either<
WorkspaceError<"INVALID_HANDLE" | "INVALID_PROVIDER">,
Handle<RESTCollectionLevelAuthHeadersView>
>
> {
const collectionHandleRef = collectionHandle.get()
if (collectionHandleRef.value.type === "invalid") {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_HANDLE" })
}
const provider = this.registeredProviders.get(
collectionHandleRef.value.data.providerID
)
if (!provider) {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_PROVIDER" })
}
const result =
await provider.getRESTCollectionLevelAuthHeadersView(collectionHandle)
if (E.isLeft(result)) {
return E.left({ type: "PROVIDER_ERROR", error: result.left })
}
return E.right(result.right)
}
public async getRESTSearchResultsView(
workspaceHandle: Handle<Workspace>,
searchQuery: Ref<string>
): Promise<
E.Either<
WorkspaceError<"INVALID_HANDLE" | "INVALID_PROVIDER">,
Handle<RESTSearchResultsView>
>
> {
const workspaceHandleRef = workspaceHandle.get()
if (workspaceHandleRef.value.type === "invalid") {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_HANDLE" })
}
const provider = this.registeredProviders.get(
workspaceHandleRef.value.data.providerID
)
if (!provider) {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_PROVIDER" })
}
const result = await provider.getRESTSearchResultsView(
workspaceHandle,
searchQuery
)
if (E.isLeft(result)) {
return E.left({ type: "PROVIDER_ERROR", error: result.left })
}
return E.right(result.right)
}
public async getRESTCollectionJSONView(
workspaceHandle: Handle<Workspace>
): Promise<
E.Either<
WorkspaceError<"INVALID_HANDLE" | "INVALID_PROVIDER">,
Handle<RESTCollectionJSONView>
>
> {
const workspaceHandleRef = workspaceHandle.get()
if (workspaceHandleRef.value.type === "invalid") {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_HANDLE" })
}
const provider = this.registeredProviders.get(
workspaceHandleRef.value.data.providerID
)
if (!provider) {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_PROVIDER" })
}
const result = await provider.getRESTCollectionJSONView(workspaceHandle)
if (E.isLeft(result)) {
return E.left({ type: "PROVIDER_ERROR", error: result.left })
}
return E.right(result.right)
}
public registerWorkspaceProvider(provider: WorkspaceProvider) {
if (this.registeredProviders.has(provider.providerID)) {
console.warn(
"Ignoring attempt to re-register workspace provider that is already existing:",
provider
)
return
}
this.registeredProviders.set(provider.providerID, markRaw(provider))
}
}

View File

@@ -1,111 +0,0 @@
import { Ref } from "vue"
import * as E from "fp-ts/Either"
import { Handle } from "./handle"
import {
Workspace,
WorkspaceCollection,
WorkspaceDecor,
WorkspaceRequest,
} from "./workspace"
import {
RESTCollectionLevelAuthHeadersView,
RESTCollectionChildrenView,
RootRESTCollectionView,
RESTSearchResultsView,
RESTCollectionJSONView,
} from "./view"
import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data"
export interface WorkspaceProvider {
providerID: string
workspaceDecor?: Ref<WorkspaceDecor>
getWorkspaceHandle(
workspaceID: string
): Promise<E.Either<unknown, Handle<Workspace>>>
getCollectionHandle(
workspaceHandle: Handle<Workspace>,
collectionID: string
): Promise<E.Either<unknown, Handle<WorkspaceCollection>>>
getRequestHandle(
workspaceHandle: Handle<Workspace>,
requestID: string
): Promise<E.Either<unknown, Handle<WorkspaceRequest>>>
getRESTRootCollectionView(
workspaceHandle: Handle<Workspace>
): Promise<E.Either<never, Handle<RootRESTCollectionView>>>
getRESTCollectionChildrenView(
collectionHandle: Handle<WorkspaceCollection>
): Promise<E.Either<never, Handle<RESTCollectionChildrenView>>>
getRESTCollectionLevelAuthHeadersView(
collectionHandle: Handle<WorkspaceCollection>
): Promise<E.Either<never, Handle<RESTCollectionLevelAuthHeadersView>>>
getRESTSearchResultsView(
workspaceHandle: Handle<Workspace>,
searchQuery: Ref<string>
): Promise<E.Either<never, Handle<RESTSearchResultsView>>>
getRESTCollectionJSONView(
workspaceHandle: Handle<Workspace>
): Promise<E.Either<never, Handle<RESTCollectionJSONView>>>
createRESTRootCollection(
workspaceHandle: Handle<Workspace>,
newCollection: Partial<Exclude<HoppCollection, "id">> & { name: string }
): Promise<E.Either<unknown, Handle<WorkspaceCollection>>>
createRESTChildCollection(
parentCollectionHandle: Handle<WorkspaceCollection>,
newChildCollection: Partial<HoppCollection> & { name: string }
): Promise<E.Either<unknown, Handle<WorkspaceCollection>>>
updateRESTCollection(
collectionHandle: Handle<WorkspaceCollection>,
updatedCollection: Partial<HoppCollection>
): Promise<E.Either<unknown, void>>
removeRESTCollection(
collectionHandle: Handle<WorkspaceCollection>
): Promise<E.Either<unknown, void>>
createRESTRequest(
parentCollectionHandle: Handle<WorkspaceCollection>,
newRequest: HoppRESTRequest
): Promise<E.Either<unknown, Handle<WorkspaceRequest>>>
updateRESTRequest(
requestHandle: Handle<WorkspaceRequest>,
updatedRequest: Partial<HoppRESTRequest>
): Promise<E.Either<unknown, void>>
removeRESTRequest(
requestHandle: Handle<WorkspaceRequest>
): Promise<E.Either<unknown, void>>
importRESTCollections(
workspaceHandle: Handle<Workspace>,
collections: HoppCollection[]
): Promise<E.Either<unknown, Handle<WorkspaceCollection>>>
exportRESTCollections(
workspaceHandle: Handle<Workspace>,
collections: HoppCollection[]
): Promise<E.Either<unknown, void>>
exportRESTCollection(
collectionHandle: Handle<WorkspaceCollection>,
collection: HoppCollection
): Promise<E.Either<unknown, void>>
reorderRESTCollection(
collectionHandle: Handle<WorkspaceCollection>,
destinationCollectionID: string | null
): Promise<E.Either<unknown, void>>
moveRESTCollection(
collectionHandle: Handle<WorkspaceCollection>,
destinationCollectionID: string | null
): Promise<E.Either<unknown, void>>
reorderRESTRequest(
requestHandle: Handle<WorkspaceRequest>,
destinationCollectionID: string,
destinationRequestID: string | null
): Promise<E.Either<unknown, void>>
moveRESTRequest(
requestHandle: Handle<WorkspaceRequest>,
destinationCollectionID: string
): Promise<E.Either<unknown, void>>
}

View File

@@ -1,52 +0,0 @@
import { getDefaultRESTRequest } from "@hoppscotch/data"
import { ref, computed } from "vue"
import { HandleState } from "../../handle"
import { WorkspaceRequest } from "../../workspace"
export const generateIssuedHandleValues = (
collectionsAndRequests: { collectionID: string; requestCount: number }[]
) => {
const providerID = "PERSONAL_WORKSPACE_PROVIDER"
const workspaceID = "personal"
const issuedHandleValues: HandleState<WorkspaceRequest, unknown>[] = []
collectionsAndRequests.forEach(({ collectionID, requestCount }) => {
for (let i = 0; i < requestCount; i++) {
const requestID = `${collectionID}/${i}`
issuedHandleValues.push({
type: "ok" as const,
data: {
providerID: providerID,
workspaceID: workspaceID,
collectionID,
requestID,
request: {
...getDefaultRESTRequest(),
name: `req-${requestID}`,
},
},
})
}
})
return issuedHandleValues
}
export const getWritableHandle = (
value: HandleState<WorkspaceRequest, unknown>
) => {
const handleRefData = ref(value)
const writableHandle = computed({
get() {
return handleRefData.value
},
set(newValue) {
handleRefData.value = newValue
},
})
return writableHandle
}

View File

@@ -1,309 +0,0 @@
import { computed, markRaw, reactive, ref } from "vue"
import { useTimestamp } from "@vueuse/core"
import { Service } from "dioc"
import { WorkspaceProvider } from "../provider"
import * as E from "fp-ts/Either"
import { Handle, HandleRef } from "../handle"
import { Workspace, WorkspaceCollection } from "../workspace"
import { NewWorkspaceService } from ".."
import TestWorkspaceSelector from "~/components/workspace/TestWorkspaceSelector.vue"
import { RESTCollectionChildrenView, RootRESTCollectionView } from "../view"
import IconUser from "~icons/lucide/user"
import { get } from "lodash-es"
type TestReqDef = {
name: string
}
type TestCollDef = {
name: string
collections: TestCollDef[]
requests: TestReqDef[]
}
const timestamp = useTimestamp({ interval: 3000 })
// const timestamp = ref(Date.now())
const testData = reactive({
workspaceA: {
name: computed(() => `Workspace A: ${timestamp.value}`),
collections: [
<TestCollDef>{
name: "Collection A",
collections: [
{
name: "Collection B",
collections: [
{ name: "Collection C", collections: [], requests: [] },
],
requests: [],
},
],
requests: [{ name: "Request C" }],
},
],
},
workspaceB: {
name: "Workspace B",
collections: [
<TestCollDef>{
name: "Collection D",
collections: [{ name: "Collection E", collections: [], requests: [] }],
requests: [{ name: "Request F" }],
},
],
},
})
;(window as any).testData = testData
export class TestWorkspaceProviderService
extends Service
implements WorkspaceProvider
{
public static readonly ID = "TEST_WORKSPACE_PROVIDER_SERVICE"
public providerID = "TEST_WORKSPACE_PROVIDER"
public workspaceDecor = ref({
workspaceSelectorComponent: markRaw(TestWorkspaceSelector),
headerCurrentIcon: markRaw(IconUser),
workspaceSelectorPriority: 10,
})
private readonly workspaceService = this.bind(NewWorkspaceService)
override onServiceInit() {
this.workspaceService.registerWorkspaceProvider(this)
}
public createRESTRootCollection(
workspaceHandle: HandleRef<Workspace>,
collectionName: string
): Promise<
E.Either<"INVALID_WORKSPACE_HANDLE", Handle<WorkspaceCollection>>
> {
if (workspaceHandle.value.type !== "ok") {
return Promise.resolve(E.left("INVALID_WORKSPACE_HANDLE" as const))
}
const workspaceID = workspaceHandle.value.data.workspaceID
const newCollID =
testData[workspaceID as keyof typeof testData].collections.length
testData[workspaceID as keyof typeof testData].collections.push({
name: collectionName,
collections: [],
requests: [],
})
return this.getCollectionHandle(workspaceHandle, newCollID.toString())
}
public createRESTChildCollection(
parentCollHandle: HandleRef<WorkspaceCollection>,
collectionName: string
): Promise<E.Either<unknown, Handle<WorkspaceCollection>>> {
// TODO: Implement
throw new Error("Method not implemented.")
}
public getWorkspaceHandle(
workspaceID: string
): Promise<E.Either<never, Handle<Workspace>>> {
return Promise.resolve(
E.right(
computed(() => {
if (!(workspaceID in testData)) {
return {
type: "invalid",
reason: "WORKSPACE_WENT_OUT" as const,
}
}
return {
type: "ok",
data: {
providerID: this.providerID,
workspaceID,
name: testData[workspaceID as keyof typeof testData].name,
collectionsAreReadonly: false,
},
}
})
)
)
}
public getCollectionHandle(
workspaceHandle: HandleRef<Workspace>,
collectionID: string
): Promise<
E.Either<"INVALID_WORKSPACE_HANDLE", Handle<WorkspaceCollection>>
> {
return Promise.resolve(
E.right(
computed(() => {
if (workspaceHandle.value.type !== "ok") {
return {
type: "invalid",
reason: "WORKSPACE_INVALIDATED" as const,
}
}
const workspaceID = workspaceHandle.value.data.workspaceID
const collectionPath = collectionID
.split("/")
.flatMap((x) => ["collections", x])
const result: TestCollDef | undefined = get(
testData[workspaceID as keyof typeof testData],
collectionPath
)
if (!result) {
return {
type: "invalid",
reason: "INVALID_COLL_ID",
}
}
return {
type: "ok",
data: {
providerID: this.providerID,
workspaceID,
collectionID,
name: result.name,
},
}
})
)
)
}
public getRESTCollectionChildrenView(
collectionHandle: HandleRef<WorkspaceCollection>
): Promise<E.Either<never, Handle<RESTCollectionChildrenView>>> {
return Promise.resolve(
E.right(
computed(() => {
if (collectionHandle.value.type === "invalid") {
return {
type: "invalid",
reason: "COLL_HANDLE_IS_INVALID" as const,
}
}
const workspaceID = collectionHandle.value.data.workspaceID
const collectionID = collectionHandle.value.data.collectionID
if (!(workspaceID in testData)) {
return {
type: "invalid",
reason: "WORKSPACE_NOT_PRESENT" as const,
}
}
const collectionPath = collectionID
.split("/")
.flatMap((x) => ["collections", x])
return markRaw({
type: "ok",
data: {
providerID: this.providerID,
workspaceID,
collectionID,
loading: ref(false),
content: computed(() => [
...(
get(testData[workspaceID as keyof typeof testData], [
...collectionPath,
"collections",
]) as TestCollDef[]
).map((item, i) => ({
type: "collection" as const,
value: {
collectionID: `${collectionID}/${i}`,
name: item.name,
},
})),
...(
get(testData[workspaceID as keyof typeof testData], [
...collectionPath,
"requests",
]) as TestReqDef[]
).map((item, i) => ({
type: "request" as const,
value: {
requestID: `${collectionID}/${i}`,
name: item.name,
method: "get",
},
})),
]),
},
})
})
)
)
}
public getRESTRootCollectionView(
workspaceHandle: HandleRef<Workspace>
): Promise<E.Either<never, Handle<RootRESTCollectionView>>> {
return Promise.resolve(
E.right(
computed(() => {
if (workspaceHandle.value.type === "invalid") {
return {
type: "invalid",
reason: "WORKSPACE_IS_INVALID" as const,
}
}
const workspaceID = workspaceHandle.value.data.workspaceID
if (!(workspaceID in testData)) {
return {
type: "invalid",
reason: "WORKSPACE_NOT_PRESENT" as const,
}
}
return markRaw({
type: "ok",
data: {
providerID: this.providerID,
workspaceID,
loading: ref(false),
collections: computed(() => {
return testData[
workspaceID as keyof typeof testData
].collections.map((x, i) => ({
collectionID: i.toString(),
name: x.name,
}))
}),
},
})
})
)
)
}
public getWorkspaceCandidates() {
return computed(() =>
Object.keys(testData).map((workspaceID) => ({
id: workspaceID,
name: testData[workspaceID as keyof typeof testData].name,
}))
)
}
}

View File

@@ -1,64 +0,0 @@
import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data"
import { Ref } from "vue"
import { HoppInheritedRESTProperty } from "~/helpers/types/HoppInheritedProperties"
export type RESTCollectionLevelAuthHeadersView = {
auth: HoppInheritedRESTProperty["auth"]
headers: HoppInheritedRESTProperty["headers"]
}
export type RESTCollectionViewCollection = {
collectionID: string
isLastItem: boolean
name: string
parentCollectionID: string | null
}
export type RESTCollectionViewRequest = {
collectionID: string
requestID: string
request: HoppRESTRequest
isLastItem: boolean
}
export type RESTCollectionViewItem =
| { type: "collection"; value: RESTCollectionViewCollection }
| { type: "request"; value: RESTCollectionViewRequest }
export interface RootRESTCollectionView {
providerID: string
workspaceID: string
loading: Ref<boolean>
collections: Ref<RESTCollectionViewCollection[]>
}
export interface RESTCollectionChildrenView {
providerID: string
workspaceID: string
collectionID: string
loading: Ref<boolean>
content: Ref<RESTCollectionViewItem[]>
}
export interface RESTSearchResultsView {
providerID: string
workspaceID: string
loading: Ref<boolean>
results: Ref<HoppCollection[]>
onSessionEnd: () => void
}
export interface RESTCollectionJSONView {
providerID: string
workspaceID: string
content: string
}

View File

@@ -1,35 +0,0 @@
import { HoppRESTRequest } from "@hoppscotch/data"
import { Component } from "vue"
export type Workspace = {
providerID: string
workspaceID: string
name: string
}
export type WorkspaceCollection = {
providerID: string
workspaceID: string
collectionID: string
name: string
}
export type WorkspaceRequest = {
providerID: string
workspaceID: string
collectionID: string
requestID: string
request: HoppRESTRequest
}
export type WorkspaceDecor = {
headerComponent?: Component
headerCurrentIcon?: Component | object
workspaceSelectorComponent?: Component
workspaceSelectorPriority?: number
}

View File

@@ -580,6 +580,8 @@ describe("PersistenceService", () => {
invokeSetupLocalPersistence()
// toastErrorFn = vi.fn()
expect(getItemSpy).toHaveBeenCalledWith(settingsKey)
expect(toastErrorFn).not.toHaveBeenCalledWith(settingsKey)

View File

@@ -492,15 +492,6 @@ const HoppRESTResponseSchema = z.discriminatedUnion("type", [
const HoppRESTSaveContextSchema = z.nullable(
z.discriminatedUnion("originLocation", [
z
.object({
originLocation: z.literal("workspace-user-collection"),
workspaceID: z.optional(z.string()),
providerID: z.optional(z.string()),
requestID: z.optional(z.string()),
requestHandle: z.optional(z.record(z.unknown())),
})
.strict(),
z
.object({
originLocation: z.literal("user-collection"),

View File

@@ -1,9 +1,9 @@
import { Container } from "dioc"
import { isEqual } from "lodash-es"
import { computed } from "vue"
import { getDefaultRESTRequest } from "~/helpers/rest/default"
import { HoppRESTDocument, HoppRESTSaveContext } from "~/helpers/rest/document"
import { TabService } from "./tab"
import { Container } from "dioc"
export class RESTTabService extends TabService<HoppRESTDocument> {
public static readonly ID = "REST_TAB_SERVICE"
@@ -30,18 +30,17 @@ export class RESTTabService extends TabService<HoppRESTDocument> {
lastActiveTabID: this.currentTabID.value,
orderedDocs: this.tabOrdering.value.map((tabID) => {
const tab = this.tabMap.get(tabID)! // tab ordering is guaranteed to have value for this key
return {
tabID: tab.id,
doc: {
...this.getPersistedDocument(tab.document),
...tab.document,
response: null,
},
}
}),
}))
public getTabRefWithSaveContext(ctx: Partial<HoppRESTSaveContext>) {
public getTabRefWithSaveContext(ctx: HoppRESTSaveContext) {
for (const tab of this.tabMap.values()) {
// For `team-collection` request id can be considered unique
if (ctx?.originLocation === "team-collection") {
@@ -51,40 +50,8 @@ export class RESTTabService extends TabService<HoppRESTDocument> {
) {
return this.getTabRef(tab.id)
}
} else if (ctx?.originLocation === "user-collection") {
if (isEqual(ctx, tab.document.saveContext)) {
return this.getTabRef(tab.id)
}
} else if (
ctx?.originLocation === "workspace-user-collection" &&
tab.document.saveContext?.originLocation === "workspace-user-collection"
) {
const requestHandle = tab.document.saveContext.requestHandle
if (!ctx.requestHandle || !requestHandle) {
continue
}
const tabRequestHandleRef = requestHandle.get()
const requestHandleRef = ctx.requestHandle.get()
if (
requestHandleRef.value.type === "invalid" ||
tabRequestHandleRef.value.type === "invalid"
) {
continue
}
if (
requestHandleRef.value.data.providerID ===
tabRequestHandleRef.value.data.providerID &&
requestHandleRef.value.data.workspaceID ===
tabRequestHandleRef.value.data.workspaceID &&
requestHandleRef.value.data.requestID ===
tabRequestHandleRef.value.data.requestID
) {
return this.getTabRef(tab.id)
}
} else if (isEqual(ctx, tab.document.saveContext)) {
return this.getTabRef(tab.id)
}
}
@@ -95,20 +62,7 @@ export class RESTTabService extends TabService<HoppRESTDocument> {
let count = 0
for (const tab of this.tabMap.values()) {
if (tab.document.isDirty) {
count++
continue
}
if (
tab.document.saveContext?.originLocation === "workspace-user-collection"
) {
const requestHandle = tab.document.saveContext.requestHandle
if (requestHandle?.get().value.type === "invalid") {
count++
}
}
if (tab.document.isDirty) count++
}
return count

View File

@@ -1,6 +1,5 @@
import { refWithControl } from "@vueuse/core"
import { Service } from "dioc"
import * as E from "fp-ts/Either"
import { v4 as uuidV4 } from "uuid"
import {
ComputedRef,
@@ -11,23 +10,16 @@ import {
shallowReadonly,
watch,
} from "vue"
import { HoppRESTDocument } from "~/helpers/rest/document"
import {
HoppTab,
PersistableTabState,
TabService as TabServiceInterface,
} from "."
import { NewWorkspaceService } from "../new-workspace"
import { Handle } from "../new-workspace/handle"
import { WorkspaceRequest } from "../new-workspace/workspace"
export abstract class TabService<Doc>
extends Service
implements TabServiceInterface<Doc>
{
private workspaceService = this.bind(NewWorkspaceService)
protected tabMap = reactive(new Map<string, HoppTab<Doc>>())
protected tabOrdering = ref<string[]>(["test"])
@@ -44,9 +36,9 @@ export abstract class TabService<Doc>
},
})
public currentActiveTab = computed(() => {
return this.tabMap.get(this.currentTabID.value)!
}) // Guaranteed to not be undefined
public currentActiveTab = computed(
() => this.tabMap.get(this.currentTabID.value)!
) // Guaranteed to not be undefined
protected watchCurrentTabID() {
watch(
@@ -90,63 +82,15 @@ export abstract class TabService<Doc>
this.currentTabID.value = tabID
}
public async loadTabsFromPersistedState(
data: PersistableTabState<Doc>
): Promise<void> {
public loadTabsFromPersistedState(data: PersistableTabState<Doc>): void {
if (data) {
this.tabMap.clear()
this.tabOrdering.value = []
for (const doc of data.orderedDocs) {
let requestHandle: Handle<WorkspaceRequest> | null = null
let resolvedTabDoc = doc.doc
// TODO: Account for GQL
const { saveContext } = doc.doc as HoppRESTDocument
if (saveContext?.originLocation === "workspace-user-collection") {
const { providerID, requestID, workspaceID } = saveContext
if (!providerID || !workspaceID || !requestID) {
continue
}
const workspaceHandleResult =
await this.workspaceService.getWorkspaceHandle(
providerID!,
workspaceID!
)
if (E.isLeft(workspaceHandleResult)) {
continue
}
const workspaceHandle = workspaceHandleResult.right
const requestHandleResult =
await this.workspaceService.getRequestHandle(
workspaceHandle,
requestID!
)
if (E.isRight(requestHandleResult)) {
requestHandle = requestHandleResult.right
const { originLocation } = saveContext
resolvedTabDoc = {
...resolvedTabDoc,
saveContext: {
originLocation,
requestHandle,
},
}
}
}
this.tabMap.set(doc.tabID, {
id: doc.tabID,
document: resolvedTabDoc,
document: doc.doc,
})
this.tabOrdering.value.push(doc.tabID)
@@ -155,11 +99,10 @@ export abstract class TabService<Doc>
this.setActiveTab(data.lastActiveTabID)
}
}
public getActiveTabs(): Readonly<ComputedRef<HoppTab<Doc>[]>> {
return shallowReadonly(
computed(() =>
this.tabOrdering.value.map((x) => this.tabMap.get(x) as HoppTab<Doc>)
)
computed(() => this.tabOrdering.value.map((x) => this.tabMap.get(x)!))
)
}
@@ -237,55 +180,13 @@ export abstract class TabService<Doc>
this.currentTabID.value = tabID
}
public getPersistedDocument(tabDoc: Doc): Doc {
const { saveContext } = tabDoc as HoppRESTDocument
if (saveContext?.originLocation !== "workspace-user-collection") {
return tabDoc
}
// TODO: Investigate why requestHandle is available unwrapped here
const requestHandle = saveContext.requestHandle
if (!requestHandle) {
return tabDoc
}
const requestHandleRef = requestHandle.get()
if (requestHandleRef.value.type === "invalid") {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { requestHandle, ...rest } = saveContext
// Return the document without the handle
return {
...tabDoc,
saveContext: rest,
}
}
const { providerID, workspaceID, requestID } = requestHandleRef.value.data
// Return the document without the handle
return {
...tabDoc,
saveContext: {
originLocation: "workspace-user-collection",
requestID,
providerID,
workspaceID,
},
}
}
public persistableTabState = computed<PersistableTabState<Doc>>(() => ({
lastActiveTabID: this.currentTabID.value,
orderedDocs: this.tabOrdering.value.map((tabID) => {
const tab = this.tabMap.get(tabID)! // tab ordering is guaranteed to have value for this key
return {
tabID: tab.id,
doc: this.getPersistedDocument(tab.document),
doc: tab.document,
}
}),
}))

View File

@@ -37,7 +37,7 @@
"rxjs": "7.8.1",
"stream-browserify": "3.0.0",
"util": "0.12.5",
"vue": "3.4.27",
"vue": "3.3.9",
"workbox-window": "7.0.0",
"zod": "3.22.4"
},

861
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff