Compare commits
85 Commits
feat/pat-u
...
refactor/w
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c74d0f865 | ||
|
|
8b930a6d3d | ||
|
|
f4a37f19c9 | ||
|
|
86b17e2bd3 | ||
|
|
e0083aa70d | ||
|
|
25b0818016 | ||
|
|
648cc8f5bd | ||
|
|
6032cbb17b | ||
|
|
52bff8ee68 | ||
|
|
e342e53db1 | ||
|
|
141652ffb6 | ||
|
|
bb57b2248c | ||
|
|
5d8da5fe49 | ||
|
|
c8f0142b16 | ||
|
|
2f2273ee2c | ||
|
|
b239b6b4a6 | ||
|
|
bbac317b71 | ||
|
|
412daa4d17 | ||
|
|
0abdc63f0e | ||
|
|
9e8112a4e5 | ||
|
|
5aa57fce3f | ||
|
|
8b65090dfb | ||
|
|
7ca94a99b7 | ||
|
|
fe3adeeb17 | ||
|
|
3a195711a4 | ||
|
|
6cde6200ae | ||
|
|
e5e1260632 | ||
|
|
90c9f2a9b1 | ||
|
|
db9ba17529 | ||
|
|
197d253e8b | ||
|
|
cd92dfec47 | ||
|
|
8467417e7a | ||
|
|
f6067f14aa | ||
|
|
3fd85df84b | ||
|
|
01573cc51c | ||
|
|
b19486ea03 | ||
|
|
a729dfcacb | ||
|
|
62612e6c51 | ||
|
|
f87a4c81d8 | ||
|
|
116f2fd279 | ||
|
|
7e2deaac5b | ||
|
|
240b131e06 | ||
|
|
5b3986a53f | ||
|
|
84fc31e8a9 | ||
|
|
c0978c3b20 | ||
|
|
d70d5bdb16 | ||
|
|
c30ee5becc | ||
|
|
5a64cdb7bc | ||
|
|
89f2479845 | ||
|
|
46654853f0 | ||
|
|
af7e6b70cd | ||
|
|
7c52c6b79d | ||
|
|
fe01322bf7 | ||
|
|
0a0f441da1 | ||
|
|
d0c7c4a245 | ||
|
|
076006c4a6 | ||
|
|
6ed9c09f06 | ||
|
|
8483339005 | ||
|
|
cd23bb63c1 | ||
|
|
f4ea999d2d | ||
|
|
c43e4fcefd | ||
|
|
316dc8f759 | ||
|
|
89f7c2ce5e | ||
|
|
0d00826019 | ||
|
|
00285df348 | ||
|
|
b821f452cf | ||
|
|
faa0bf7714 | ||
|
|
3a176f6620 | ||
|
|
7549e456c8 | ||
|
|
68795a5017 | ||
|
|
b0c72fd295 | ||
|
|
63eca80ff6 | ||
|
|
30b6a67505 | ||
|
|
97899ec023 | ||
|
|
2c47a63ca0 | ||
|
|
a0e373a4f3 | ||
|
|
392b2fc48d | ||
|
|
c1a8a871d2 | ||
|
|
1abbdb0fe0 | ||
|
|
f0f504d10e | ||
|
|
f0dab55c99 | ||
|
|
d6a8e60239 | ||
|
|
89bcc58de6 | ||
|
|
ab7df212c2 | ||
|
|
29e25b0ead |
@@ -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:
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"vue": "3.3.9"
|
||||
"vue": "3.4.27"
|
||||
},
|
||||
"packageExtensions": {
|
||||
"httpsnippet@3.0.1": {
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "lastLoggedOn" TIMESTAMP(3);
|
||||
@@ -1,19 +0,0 @@
|
||||
|
||||
-- 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;
|
||||
@@ -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,26 +89,24 @@ 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?
|
||||
lastLoggedOn DateTime?
|
||||
createdOn DateTime @default(now()) @db.Timestamp(3)
|
||||
invitedUsers InvitedUsers[]
|
||||
shortcodes Shortcode[]
|
||||
personalAccessTokens PersonalAccessToken[]
|
||||
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[]
|
||||
}
|
||||
|
||||
model Account {
|
||||
@@ -220,14 +218,3 @@ 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)
|
||||
}
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
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 {}
|
||||
@@ -1,195 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,203 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
// Inputs to create a new PAT
|
||||
export class CreateAccessTokenDto {
|
||||
label: string;
|
||||
expiryInDays: number | null;
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
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 };
|
||||
}
|
||||
@@ -74,7 +74,6 @@ const dbAdminUsers: DbUser[] = [
|
||||
refreshToken: 'refreshToken',
|
||||
currentRESTSession: '',
|
||||
currentGQLSession: '',
|
||||
lastLoggedOn: new Date(),
|
||||
createdOn: new Date(),
|
||||
},
|
||||
{
|
||||
@@ -86,10 +85,20 @@ 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', () => {
|
||||
|
||||
@@ -27,7 +27,6 @@ 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: [
|
||||
@@ -103,7 +102,6 @@ import { AccessTokenModule } from './access-token/access-token.module';
|
||||
PosthogModule,
|
||||
ScheduleModule.forRoot(),
|
||||
HealthModule,
|
||||
AccessTokenModule,
|
||||
],
|
||||
providers: [GQLComplexityPlugin],
|
||||
controllers: [AppController],
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
Request,
|
||||
Res,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { SignInMagicDto } from './dto/signin-magic.dto';
|
||||
@@ -28,7 +27,6 @@ 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' })
|
||||
@@ -112,7 +110,6 @@ 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);
|
||||
@@ -138,7 +135,6 @@ 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);
|
||||
@@ -164,7 +160,6 @@ 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);
|
||||
|
||||
@@ -51,7 +51,6 @@ const user: AuthUser = {
|
||||
photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute',
|
||||
isAdmin: false,
|
||||
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
||||
lastLoggedOn: currentTime,
|
||||
createdOn: currentTime,
|
||||
currentGQLSession: {},
|
||||
currentRESTSession: {},
|
||||
@@ -173,11 +172,9 @@ 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({
|
||||
@@ -200,11 +197,9 @@ 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({
|
||||
@@ -244,7 +239,7 @@ describe('verifyMagicLinkTokens', () => {
|
||||
// generateAuthTokens
|
||||
mockJWT.sign.mockReturnValue(user.refreshToken);
|
||||
// UpdateUserRefreshToken
|
||||
mockUser.updateUserRefreshToken.mockResolvedValueOnce(
|
||||
mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(
|
||||
E.left(USER_NOT_FOUND),
|
||||
);
|
||||
|
||||
@@ -269,7 +264,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');
|
||||
|
||||
@@ -285,7 +280,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({
|
||||
@@ -297,7 +292,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),
|
||||
);
|
||||
|
||||
@@ -324,7 +319,7 @@ describe('refreshAuthTokens', () => {
|
||||
// generateAuthTokens
|
||||
mockJWT.sign.mockReturnValue(user.refreshToken);
|
||||
// UpdateUserRefreshToken
|
||||
mockUser.updateUserRefreshToken.mockResolvedValueOnce(
|
||||
mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(
|
||||
E.left(USER_NOT_FOUND),
|
||||
);
|
||||
|
||||
@@ -353,7 +348,7 @@ describe('refreshAuthTokens', () => {
|
||||
// generateAuthTokens
|
||||
mockJWT.sign.mockReturnValue('sdhjcbjsdhcbshjdcb');
|
||||
// UpdateUserRefreshToken
|
||||
mockUser.updateUserRefreshToken.mockResolvedValueOnce(
|
||||
mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(
|
||||
E.right({
|
||||
...user,
|
||||
refreshToken: 'sdhjcbjsdhcbshjdcb',
|
||||
|
||||
@@ -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,8 +320,6 @@ export class AuthService {
|
||||
statusCode: HttpStatus.NOT_FOUND,
|
||||
});
|
||||
|
||||
this.usersService.updateUserLastLoggedOn(passwordlessTokens.value.userUid);
|
||||
|
||||
return E.right(tokens.right);
|
||||
}
|
||||
|
||||
|
||||
@@ -761,39 +761,3 @@ 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';
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
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;
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
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);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,6 @@ const user: AuthUser = {
|
||||
photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute',
|
||||
isAdmin: false,
|
||||
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
||||
lastLoggedOn: createdOn,
|
||||
createdOn: createdOn,
|
||||
currentGQLSession: {},
|
||||
currentRESTSession: {},
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { TeamRequest } from '@prisma/client';
|
||||
|
||||
// Type of data returned from the query to obtain all search results
|
||||
export type SearchQueryReturnType = {
|
||||
id: string;
|
||||
@@ -14,12 +12,3 @@ 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[];
|
||||
};
|
||||
|
||||
@@ -12,7 +12,6 @@ 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';
|
||||
@@ -20,18 +19,15 @@ 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();
|
||||
@@ -43,7 +39,6 @@ const user: AuthUser = {
|
||||
photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute',
|
||||
isAdmin: false,
|
||||
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
||||
lastLoggedOn: currentTime,
|
||||
createdOn: currentTime,
|
||||
currentGQLSession: {},
|
||||
currentRESTSession: {},
|
||||
@@ -1743,63 +1738,3 @@ 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: [],
|
||||
// });
|
||||
// });
|
||||
});
|
||||
|
||||
@@ -18,34 +18,23 @@ 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,
|
||||
TeamRequest,
|
||||
} from '@prisma/client';
|
||||
import { Prisma, TeamCollection as DBTeamCollection } from '@prisma/client';
|
||||
import { CollectionFolder } from 'src/types/CollectionFolder';
|
||||
import { stringToJson } from 'src/utils';
|
||||
import { CollectionSearchNode } from 'src/types/CollectionSearchNode';
|
||||
import {
|
||||
GetCollectionResponse,
|
||||
ParentTreeQueryReturnType,
|
||||
SearchQueryReturnType,
|
||||
} from './helper';
|
||||
import { 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;
|
||||
@@ -1355,95 +1344,4 @@ 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,24 +6,19 @@ 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 = {
|
||||
@@ -385,47 +380,4 @@ 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,17 +6,14 @@ 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;
|
||||
@@ -245,30 +242,4 @@ 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
export type AccessToken = {
|
||||
id: string;
|
||||
label: string;
|
||||
createdOn: Date;
|
||||
lastUsedOn: Date;
|
||||
expiresOn: null | Date;
|
||||
};
|
||||
@@ -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 | Record<string, string>;
|
||||
message: string;
|
||||
statusCode: HttpStatus;
|
||||
};
|
||||
|
||||
@@ -38,7 +38,6 @@ const user: AuthUser = {
|
||||
photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute',
|
||||
isAdmin: false,
|
||||
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
||||
lastLoggedOn: currentTime,
|
||||
createdOn: currentTime,
|
||||
currentGQLSession: {},
|
||||
currentRESTSession: {},
|
||||
|
||||
@@ -41,7 +41,6 @@ const user: AuthUser = {
|
||||
photoURL: 'https://example.com/photo.png',
|
||||
isAdmin: false,
|
||||
refreshToken: null,
|
||||
lastLoggedOn: new Date(),
|
||||
createdOn: new Date(),
|
||||
currentGQLSession: null,
|
||||
currentRESTSession: null,
|
||||
|
||||
@@ -27,7 +27,6 @@ const user: AuthUser = {
|
||||
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
||||
currentGQLSession: {},
|
||||
currentRESTSession: {},
|
||||
lastLoggedOn: currentTime,
|
||||
createdOn: currentTime,
|
||||
};
|
||||
|
||||
|
||||
@@ -30,12 +30,6 @@ 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',
|
||||
})
|
||||
|
||||
@@ -42,7 +42,6 @@ const user: AuthUser = {
|
||||
currentRESTSession: {},
|
||||
currentGQLSession: {},
|
||||
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
||||
lastLoggedOn: currentTime,
|
||||
createdOn: currentTime,
|
||||
};
|
||||
|
||||
@@ -55,7 +54,6 @@ const adminUser: AuthUser = {
|
||||
currentRESTSession: {},
|
||||
currentGQLSession: {},
|
||||
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
||||
lastLoggedOn: currentTime,
|
||||
createdOn: currentTime,
|
||||
};
|
||||
|
||||
@@ -69,7 +67,6 @@ const users: AuthUser[] = [
|
||||
currentRESTSession: {},
|
||||
currentGQLSession: {},
|
||||
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
||||
lastLoggedOn: currentTime,
|
||||
createdOn: currentTime,
|
||||
},
|
||||
{
|
||||
@@ -81,7 +78,6 @@ const users: AuthUser[] = [
|
||||
currentRESTSession: {},
|
||||
currentGQLSession: {},
|
||||
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
||||
lastLoggedOn: currentTime,
|
||||
createdOn: currentTime,
|
||||
},
|
||||
{
|
||||
@@ -93,7 +89,6 @@ const users: AuthUser[] = [
|
||||
currentRESTSession: {},
|
||||
currentGQLSession: {},
|
||||
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
||||
lastLoggedOn: currentTime,
|
||||
createdOn: currentTime,
|
||||
},
|
||||
];
|
||||
@@ -108,7 +103,6 @@ const adminUsers: AuthUser[] = [
|
||||
currentRESTSession: {},
|
||||
currentGQLSession: {},
|
||||
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
||||
lastLoggedOn: currentTime,
|
||||
createdOn: currentTime,
|
||||
},
|
||||
{
|
||||
@@ -120,7 +114,6 @@ const adminUsers: AuthUser[] = [
|
||||
currentRESTSession: {},
|
||||
currentGQLSession: {},
|
||||
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
||||
lastLoggedOn: currentTime,
|
||||
createdOn: currentTime,
|
||||
},
|
||||
{
|
||||
@@ -132,7 +125,6 @@ const adminUsers: AuthUser[] = [
|
||||
currentRESTSession: {},
|
||||
currentGQLSession: {},
|
||||
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
|
||||
lastLoggedOn: currentTime,
|
||||
createdOn: currentTime,
|
||||
},
|
||||
];
|
||||
@@ -503,26 +495,6 @@ 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);
|
||||
|
||||
@@ -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,7 +174,6 @@ export class UserService {
|
||||
displayName: userDisplayName,
|
||||
email: profile.emails[0].value,
|
||||
photoURL: userPhotoURL,
|
||||
lastLoggedOn: new Date(),
|
||||
providerAccounts: {
|
||||
create: {
|
||||
provider: profile.provider,
|
||||
@@ -222,7 +221,7 @@ export class UserService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Update User displayName and photoURL when logged in via a SSO provider
|
||||
* Update User displayName and photoURL
|
||||
*
|
||||
* @param user User object
|
||||
* @param profile Data received from SSO provider on the users account
|
||||
@@ -237,7 +236,6 @@ 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);
|
||||
@@ -291,7 +289,7 @@ export class UserService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a user's displayName
|
||||
* Update a user's data
|
||||
* @param userUID User UID
|
||||
* @param displayName User's displayName
|
||||
* @returns a Either of User or error
|
||||
@@ -318,22 +316,6 @@ 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
|
||||
|
||||
@@ -1027,7 +1027,8 @@
|
||||
"personal": "Personal Workspace",
|
||||
"other_workspaces": "My Workspaces",
|
||||
"team": "Workspace",
|
||||
"title": "Workspaces"
|
||||
"title": "Workspaces",
|
||||
"no_workspace": "No Workspace"
|
||||
},
|
||||
"site_protection": {
|
||||
"login_to_continue": "Login to continue",
|
||||
|
||||
@@ -90,7 +90,7 @@
|
||||
"util": "0.12.5",
|
||||
"uuid": "9.0.1",
|
||||
"verzod": "0.2.2",
|
||||
"vue": "3.3.9",
|
||||
"vue": "3.4.27",
|
||||
"vue-i18n": "9.8.0",
|
||||
"vue-pdf-embed": "1.2.1",
|
||||
"vue-router": "4.2.5",
|
||||
|
||||
15
packages/hoppscotch-common/src/components.d.ts
vendored
15
packages/hoppscotch-common/src/components.d.ts
vendored
@@ -7,10 +7,6 @@ export {}
|
||||
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
AccessTokensGenerate: typeof import('./components/accessTokens/Generate.vue')['default']
|
||||
AccessTokensGenerateModal: typeof import('./components/accessTokens/GenerateModal.vue')['default']
|
||||
AccessTokensList: typeof import('./components/accessTokens/List.vue')['default']
|
||||
AccessTokensOverview: typeof import('./components/accessTokens/Overview.vue')['default']
|
||||
AppActionHandler: typeof import('./components/app/ActionHandler.vue')['default']
|
||||
AppBanner: typeof import('./components/app/Banner.vue')['default']
|
||||
AppContextMenu: typeof import('./components/app/ContextMenu.vue')['default']
|
||||
@@ -152,7 +148,7 @@ declare module 'vue' {
|
||||
IconLucideAlertTriangle: typeof import('~icons/lucide/alert-triangle')['default']
|
||||
IconLucideArrowLeft: typeof import('~icons/lucide/arrow-left')['default']
|
||||
IconLucideArrowUpRight: typeof import('~icons/lucide/arrow-up-right')['default']
|
||||
IconLucideBrush: typeof import('~icons/lucide/brush')['default']
|
||||
IconLucideBrush: (typeof import("~icons/lucide/brush"))["default"]
|
||||
IconLucideCheckCircle: typeof import('~icons/lucide/check-circle')['default']
|
||||
IconLucideChevronRight: typeof import('~icons/lucide/chevron-right')['default']
|
||||
IconLucideGlobe: typeof import('~icons/lucide/globe')['default']
|
||||
@@ -162,10 +158,9 @@ declare module 'vue' {
|
||||
IconLucideLayers: typeof import('~icons/lucide/layers')['default']
|
||||
IconLucideListEnd: typeof import('~icons/lucide/list-end')['default']
|
||||
IconLucideMinus: typeof import('~icons/lucide/minus')['default']
|
||||
IconLucideRss: typeof import('~icons/lucide/rss')['default']
|
||||
IconLucideRss: (typeof import("~icons/lucide/rss"))["default"]
|
||||
IconLucideSearch: typeof import('~icons/lucide/search')['default']
|
||||
IconLucideUsers: typeof import('~icons/lucide/users')['default']
|
||||
IconLucideVerified: typeof import('~icons/lucide/verified')['default']
|
||||
IconLucideX: typeof import('~icons/lucide/x')['default']
|
||||
ImportExportBase: typeof import('./components/importExport/Base.vue')['default']
|
||||
ImportExportImportExportList: typeof import('./components/importExport/ImportExportList.vue')['default']
|
||||
@@ -186,6 +181,10 @@ 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']
|
||||
@@ -217,6 +216,8 @@ 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']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,216 +0,0 @@
|
||||
<template>
|
||||
<HoppSmartModal dialog title="New Personal Access Token" @close="hideModal">
|
||||
<template #body>
|
||||
<template v-if="accessToken">
|
||||
<p class="text-amber-500 mb-4 border rounded-md border-amber-600 p-4">
|
||||
Make sure to copy your personal access token now. You won’t be able to
|
||||
see it again!
|
||||
</p>
|
||||
|
||||
<div
|
||||
class="rounded-md bg-primaryLight p-4 mt-4 flex items-center justify-between"
|
||||
>
|
||||
<div class="text-secondaryDark">{{ accessToken }}</div>
|
||||
<HoppButtonSecondary
|
||||
outline
|
||||
filled
|
||||
:icon="copyIcon"
|
||||
@click="copyAccessToken"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<div class="text-secondaryDark font-semibold">Label</div>
|
||||
<HoppSmartInput
|
||||
v-model="accessTokenLabel"
|
||||
placeholder=" "
|
||||
class="floating-input"
|
||||
/>
|
||||
<div class="text-secondaryLight">What's this token for?</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label for="expiration" class="text-secondaryDark font-semibold"
|
||||
>Expiration</label
|
||||
>
|
||||
|
||||
<div class="grid grid-cols-2 items-center gap-x-2">
|
||||
<tippy
|
||||
interactive
|
||||
trigger="click"
|
||||
theme="popover"
|
||||
:on-shown="tippyActions?.focus()"
|
||||
>
|
||||
<HoppSmartSelectWrapper>
|
||||
<input
|
||||
id="expiration"
|
||||
:value="expiration"
|
||||
readonly
|
||||
class="flex flex-1 cursor-pointer bg-transparent px-4 py-2 rounded border border-divider"
|
||||
/>
|
||||
</HoppSmartSelectWrapper>
|
||||
|
||||
<template #content="{ hide }">
|
||||
<div
|
||||
ref="tippyActions"
|
||||
tabindex="0"
|
||||
role="menu"
|
||||
class="flex flex-col focus:outline-none"
|
||||
@keyup.escape="hide"
|
||||
>
|
||||
<HoppSmartItem
|
||||
v-for="expirationOption in Object.keys(expirationOptions)"
|
||||
:key="expirationOption"
|
||||
:label="expirationOption"
|
||||
:icon="
|
||||
expirationOption === expiration
|
||||
? IconCircleDot
|
||||
: IconCircle
|
||||
"
|
||||
:active="expirationOption === expiration"
|
||||
:aria-selected="expirationOption === expiration"
|
||||
@click="
|
||||
() => {
|
||||
expiration = expirationOption
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</tippy>
|
||||
|
||||
<span class="text-secondaryLight">{{ expirationDateText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="text-secondaryDark font-semibold">Scope</div>
|
||||
|
||||
<p class="text-secondaryLight">
|
||||
Read-only access to workspace data.<br />
|
||||
Personal Access Tokens can't access your personal workspace.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<HoppButtonSecondary
|
||||
v-if="accessToken"
|
||||
:label="t('action.close')"
|
||||
outline
|
||||
filled
|
||||
@click="hideModal"
|
||||
/>
|
||||
|
||||
<div v-else class="flex items-center gap-x-2">
|
||||
<HoppButtonPrimary
|
||||
:loading="tokenGenerateActionLoading"
|
||||
filled
|
||||
outline
|
||||
label="Generate Token"
|
||||
@click="generateAccessToken"
|
||||
/>
|
||||
|
||||
<HoppButtonSecondary
|
||||
:label="t('action.cancel')"
|
||||
outline
|
||||
filled
|
||||
@click="hideModal"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</HoppSmartModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useToast } from "@composables/toast"
|
||||
import { refAutoReset } from "@vueuse/core"
|
||||
import { computed, ref } from "vue"
|
||||
import { TippyComponent } from "vue-tippy"
|
||||
import { copyToClipboard } from "~/helpers/utils/clipboard"
|
||||
import { shortDateTime } from "~/helpers/utils/date"
|
||||
|
||||
import IconCheck from "~icons/lucide/check"
|
||||
import IconCircle from "~icons/lucide/circle"
|
||||
import IconCircleDot from "~icons/lucide/circle-dot"
|
||||
import IconCopy from "~icons/lucide/copy"
|
||||
|
||||
const t = useI18n()
|
||||
const toast = useToast()
|
||||
|
||||
const props = defineProps<{
|
||||
tokenGenerateActionLoading: boolean
|
||||
accessToken: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "hide-modal"): void
|
||||
(
|
||||
e: "generate-access-token",
|
||||
{ label, expiryInDays }: { label: string; expiryInDays: number | null }
|
||||
): void
|
||||
}>()
|
||||
|
||||
// Template refs
|
||||
const tippyActions = ref<TippyComponent[] | null>(null)
|
||||
|
||||
const copyIcon = refAutoReset<typeof IconCopy | typeof IconCheck>(
|
||||
IconCopy,
|
||||
1000
|
||||
)
|
||||
|
||||
const accessTokenLabel = ref<string>("")
|
||||
const expiration = ref<string>("30 days")
|
||||
|
||||
const expirationOptions: Record<string, number | null> = {
|
||||
"7 days": 7,
|
||||
"30 days": 30,
|
||||
"60 days": 60,
|
||||
"90 days": 90,
|
||||
"No expiration": null,
|
||||
}
|
||||
|
||||
const expirationDateText = computed(() => {
|
||||
const chosenExpiryInDays = expirationOptions[expiration.value]
|
||||
|
||||
if (chosenExpiryInDays === null) {
|
||||
return "This token will never expire!"
|
||||
}
|
||||
|
||||
const currentDate = new Date()
|
||||
currentDate.setDate(currentDate.getDate() + chosenExpiryInDays)
|
||||
|
||||
const expirationDate = shortDateTime(currentDate, false)
|
||||
return `This token will expire on ${expirationDate}`
|
||||
})
|
||||
|
||||
const copyAccessToken = () => {
|
||||
if (!props.accessToken) {
|
||||
toast.error("error.something_went_wrong")
|
||||
return
|
||||
}
|
||||
|
||||
copyToClipboard(props.accessToken)
|
||||
copyIcon.value = IconCheck
|
||||
|
||||
toast.success(`${t("state.copied_to_clipboard")}`)
|
||||
}
|
||||
|
||||
const generateAccessToken = async () => {
|
||||
if (!accessTokenLabel.value) {
|
||||
toast.error("Please provide a label for the token")
|
||||
return
|
||||
}
|
||||
|
||||
emit("generate-access-token", {
|
||||
label: accessTokenLabel.value,
|
||||
expiryInDays: expirationOptions[expiration.value],
|
||||
})
|
||||
}
|
||||
|
||||
const hideModal = () => emit("hide-modal")
|
||||
</script>
|
||||
@@ -1,73 +0,0 @@
|
||||
<template>
|
||||
<div class="max-w-2xl space-y-4">
|
||||
<div
|
||||
v-for="{ id, label, lastUsedOn, expiresOn } in accessTokens"
|
||||
:key="id"
|
||||
class="flex items-center justify-between rounded border border-divider p-4"
|
||||
>
|
||||
<span class="font-semibold text-secondaryDark text-sm">
|
||||
{{ label }}
|
||||
</span>
|
||||
|
||||
<div class="flex items-center gap-x-4">
|
||||
<div class="text-secondaryLight space-y-1">
|
||||
<div class="space-x-1">
|
||||
<span class="font-semibold">Last used on:</span>
|
||||
<span>
|
||||
{{ shortDateTime(lastUsedOn, false) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="space-x-1">
|
||||
<span class="font-semibold">Expires on:</span>
|
||||
<span>
|
||||
{{
|
||||
expiresOn ? shortDateTime(expiresOn, false) : "No expiration"
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<HoppButtonSecondary
|
||||
label="Delete"
|
||||
filled
|
||||
outline
|
||||
@click="
|
||||
emit('delete-access-token', {
|
||||
tokenId: id,
|
||||
tokenLabel: label,
|
||||
})
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<HoppSmartIntersection
|
||||
v-if="hasMoreTokens"
|
||||
@intersecting="emit('fetch-more-tokens')"
|
||||
>
|
||||
<div v-if="tokensListLoading" class="flex flex-col items-center py-3">
|
||||
<HoppSmartSpinner />
|
||||
</div>
|
||||
</HoppSmartIntersection>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { shortDateTime } from "~/helpers/utils/date"
|
||||
import { AccessToken } from "~/pages/profile.vue"
|
||||
|
||||
defineProps<{
|
||||
accessTokens: AccessToken[]
|
||||
hasMoreTokens: boolean
|
||||
tokensListLoading: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "fetch-more-tokens"): void
|
||||
(
|
||||
e: "delete-access-token",
|
||||
{ tokenId, tokenLabel }: { tokenId: string; tokenLabel: string }
|
||||
): void
|
||||
}>()
|
||||
</script>
|
||||
@@ -1,25 +0,0 @@
|
||||
<template>
|
||||
<div class="p-4 space-y-4">
|
||||
<div class="space-y-1">
|
||||
<h4 class="font-semibold text-secondaryDark">Personal Access Tokens</h4>
|
||||
|
||||
<p class="text-secondaryLight">
|
||||
Personal access tokens currently helps you connect the CLI to your
|
||||
Hoppscotch account
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<HoppButtonSecondary
|
||||
filled
|
||||
outline
|
||||
label="Generate new token"
|
||||
@click="emit('show-access-tokens-generate-modal')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const emit = defineEmits<{
|
||||
(e: "show-access-tokens-generate-modal"): void
|
||||
}>()
|
||||
</script>
|
||||
@@ -2,74 +2,73 @@
|
||||
<div>
|
||||
<header
|
||||
ref="headerRef"
|
||||
class="grid grid-cols-5 grid-rows-1 gap-2 overflow-x-auto overflow-y-hidden p-2"
|
||||
class="flex flex-1 flex-shrink-0 items-center justify-between space-x-2 overflow-x-auto overflow-y-hidden px-2 py-2"
|
||||
@mousedown.prevent="platform.ui?.appHeader?.onHeaderAreaClick?.()"
|
||||
>
|
||||
<div
|
||||
class="col-span-2 flex items-center justify-between space-x-2"
|
||||
class="inline-flex flex-1 items-center justify-start space-x-2"
|
||||
:style="{
|
||||
paddingTop: platform.ui?.appHeader?.paddingTop?.value,
|
||||
paddingLeft: platform.ui?.appHeader?.paddingLeft?.value,
|
||||
}"
|
||||
>
|
||||
<div class="flex">
|
||||
<HoppButtonSecondary
|
||||
class="!font-bold uppercase tracking-wide !text-secondaryDark hover:bg-primaryDark focus-visible:bg-primaryDark"
|
||||
:label="t('app.name')"
|
||||
to="/"
|
||||
/>
|
||||
</div>
|
||||
<HoppButtonSecondary
|
||||
class="!font-bold uppercase tracking-wide !text-secondaryDark hover:bg-primaryDark focus-visible:bg-primaryDark"
|
||||
:label="t('app.name')"
|
||||
to="/"
|
||||
/>
|
||||
</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 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')"
|
||||
>
|
||||
<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>
|
||||
<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"
|
||||
>
|
||||
<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')"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="inline-flex items-center space-x-2">
|
||||
<TeamsMemberStack
|
||||
v-else-if="
|
||||
currentUser !== null &&
|
||||
v-if="
|
||||
workspace.type === 'team' &&
|
||||
selectedTeam &&
|
||||
selectedTeam.teamMembers.length > 1
|
||||
@@ -80,142 +79,145 @@
|
||||
@handle-click="handleTeamEdit()"
|
||||
/>
|
||||
<div
|
||||
v-if="workspaceSelectorFlagEnabled || currentUser"
|
||||
class="inline-flex items-center space-x-2"
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
<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">
|
||||
<tippy
|
||||
interactive
|
||||
trigger="click"
|
||||
theme="popover"
|
||||
:on-shown="() => accountActions.focus()"
|
||||
:on-shown="() => tippyActions.focus()"
|
||||
>
|
||||
<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>
|
||||
<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'
|
||||
"
|
||||
/>
|
||||
<template #content="{ hide }">
|
||||
<div
|
||||
ref="accountActions"
|
||||
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()"
|
||||
@click="hide()"
|
||||
>
|
||||
<WorkspaceSelector />
|
||||
<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()"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</tippy>
|
||||
<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>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<AppBanner
|
||||
v-if="bannerContent"
|
||||
:banner="bannerContent"
|
||||
@dismiss="dismissOfflineBanner"
|
||||
/>
|
||||
<AppBanner v-if="bannerContent" :banner="bannerContent" />
|
||||
<TeamsModal :show="showTeamsModal" @hide-modal="showTeamsModal = false" />
|
||||
<TeamsInvite
|
||||
v-if="workspace.type === 'team' && workspace.teamID"
|
||||
@@ -231,6 +233,7 @@
|
||||
@invite-team="inviteTeam(editingTeamName, editingTeamID)"
|
||||
@refetch-teams="refetchTeams"
|
||||
/>
|
||||
|
||||
<HoppSmartConfirmModal
|
||||
:show="confirmRemove"
|
||||
:title="t('confirm.remove_team')"
|
||||
@@ -244,40 +247,35 @@
|
||||
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
|
||||
@@ -296,11 +294,10 @@ const bannerContent = computed(() => banner.content.value?.content)
|
||||
let bannerID: number | null = null
|
||||
|
||||
const offlineBanner: BannerContent = {
|
||||
type: "warning",
|
||||
type: "info",
|
||||
text: (t) => t("helpers.offline"),
|
||||
alternateText: (t) => t("helpers.offline_short"),
|
||||
score: BANNER_PRIORITY_HIGH,
|
||||
dismissible: true,
|
||||
}
|
||||
|
||||
const network = reactive(useNetwork())
|
||||
@@ -317,8 +314,6 @@ watch(isOnline, () => {
|
||||
}
|
||||
})
|
||||
|
||||
const dismissOfflineBanner = () => banner.removeBanner(bannerID!)
|
||||
|
||||
const currentUser = useReadonlyStream(
|
||||
platform.auth.getProbableUserStream(),
|
||||
platform.auth.getProbableUser()
|
||||
@@ -336,12 +331,6 @@ 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()
|
||||
}
|
||||
@@ -376,6 +365,23 @@ 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)
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ import { defineStep } from "~/composables/step-components"
|
||||
|
||||
import { useI18n } from "~/composables/i18n"
|
||||
import { useToast } from "~/composables/toast"
|
||||
import { appendRESTCollections, restCollections$ } from "~/newstore/collections"
|
||||
import { restCollections$ } from "~/newstore/collections"
|
||||
import MyCollectionImport from "~/components/importExport/ImportExportSteps/MyCollectionImport.vue"
|
||||
|
||||
import IconFolderPlus from "~icons/lucide/folder-plus"
|
||||
@@ -47,13 +47,14 @@ import { getTeamCollectionJSON } from "~/helpers/backend/helpers"
|
||||
|
||||
import { platform } from "~/platform"
|
||||
|
||||
import { initializeDownloadCollection } from "~/helpers/import-export/export"
|
||||
import { initializeDownloadFile } 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()
|
||||
@@ -84,15 +85,45 @@ 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[]) => {
|
||||
const importResult =
|
||||
props.collectionsType.type === "my-collections"
|
||||
? await importToPersonalWorkspace(collections)
|
||||
: await importToTeamsWorkspace(collections)
|
||||
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)
|
||||
|
||||
if (E.isRight(importResult)) {
|
||||
toast.success(t("state.file_imported"))
|
||||
@@ -102,13 +133,6 @@ 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
|
||||
@@ -388,28 +412,34 @@ const HoppMyCollectionsExporter: ImporterOrExporter = {
|
||||
applicableTo: ["personal-workspace"],
|
||||
isLoading: isHoppMyCollectionExporterInProgress,
|
||||
},
|
||||
action: () => {
|
||||
action: async () => {
|
||||
if (!myCollections.value.length) {
|
||||
return toast.error(t("error.no_collections_to_export"))
|
||||
}
|
||||
|
||||
if (!activeWorkspaceHandle.value) {
|
||||
return
|
||||
}
|
||||
|
||||
isHoppMyCollectionExporterInProgress.value = true
|
||||
|
||||
const message = initializeDownloadCollection(
|
||||
myCollectionsExporter(myCollections.value),
|
||||
"Collections"
|
||||
const result = await workspaceService.exportRESTCollections(
|
||||
activeWorkspaceHandle.value,
|
||||
myCollections.value
|
||||
)
|
||||
|
||||
if (E.isRight(message)) {
|
||||
toast.success(t(message.right))
|
||||
|
||||
platform.analytics?.logEvent({
|
||||
type: "HOPP_EXPORT_COLLECTION",
|
||||
exporter: "json",
|
||||
platform: "rest",
|
||||
})
|
||||
if (E.isLeft(result)) {
|
||||
// INVALID_WORKSPACE_HANDLE
|
||||
}
|
||||
|
||||
toast.success(t("state.download_started"))
|
||||
|
||||
platform.analytics?.logEvent({
|
||||
type: "HOPP_EXPORT_COLLECTION",
|
||||
exporter: "json",
|
||||
platform: "rest",
|
||||
})
|
||||
|
||||
isHoppMyCollectionExporterInProgress.value = false
|
||||
},
|
||||
}
|
||||
@@ -443,10 +473,7 @@ const HoppTeamCollectionsExporter: ImporterOrExporter = {
|
||||
return toast.error(t("error.no_collections_to_export"))
|
||||
}
|
||||
|
||||
initializeDownloadCollection(
|
||||
exportCollectionsToJSON,
|
||||
"team-collections"
|
||||
)
|
||||
initializeDownloadFile(exportCollectionsToJSON, "team-collections")
|
||||
|
||||
platform.analytics?.logEvent({
|
||||
type: "HOPP_EXPORT_COLLECTION",
|
||||
@@ -485,7 +512,7 @@ const HoppGistCollectionsExporter: ImporterOrExporter = {
|
||||
const collectionJSON = await getCollectionJSON()
|
||||
const accessToken = currentUser.value?.accessToken
|
||||
|
||||
if (!accessToken) {
|
||||
if (!accessToken || E.isLeft(collectionJSON)) {
|
||||
toast.error(t("error.something_went_wrong"))
|
||||
isHoppGistCollectionExporterInProgress.value = false
|
||||
return
|
||||
@@ -581,6 +608,7 @@ const selectedTeamID = computed(() => {
|
||||
})
|
||||
|
||||
const getCollectionJSON = async () => {
|
||||
// TODO: Implement `getRESTCollectionJSONView` for team workspace
|
||||
if (
|
||||
props.collectionsType.type === "team-collections" &&
|
||||
props.collectionsType.selectedTeam?.teamID
|
||||
@@ -591,11 +619,33 @@ const getCollectionJSON = async () => {
|
||||
|
||||
return E.isRight(res)
|
||||
? E.right(res.right.exportCollectionsToJSON)
|
||||
: E.left(res.left)
|
||||
: E.left(res.left.error.toString())
|
||||
}
|
||||
|
||||
if (props.collectionsType.type === "my-collections") {
|
||||
return E.right(JSON.stringify(myCollections.value, null, 2))
|
||||
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.left("INVALID_SELECTED_TEAM_OR_INVALID_COLLECTION_TYPE")
|
||||
|
||||
@@ -75,7 +75,7 @@ import {
|
||||
import { useVModel } from "@vueuse/core"
|
||||
import { useService } from "dioc/vue"
|
||||
import { clone } from "lodash-es"
|
||||
import { ref, watch } from "vue"
|
||||
import { ref, toRefs, watch } from "vue"
|
||||
|
||||
import { HoppInheritedProperty } from "~/helpers/types/HoppInheritedProperties"
|
||||
import { PersistenceService } from "~/services/persistence"
|
||||
@@ -100,11 +100,15 @@ 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,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -178,15 +182,24 @@ watch(
|
||||
const saveEditedCollection = () => {
|
||||
if (!props.editingProperties) return
|
||||
const finalCollection = clone(editableCollection.value)
|
||||
delete finalCollection.body
|
||||
|
||||
const { path } = toRefs(props.editingProperties)
|
||||
|
||||
const collection = {
|
||||
path: props.editingProperties.path,
|
||||
path: path.value,
|
||||
collection: {
|
||||
...props.editingProperties.collection,
|
||||
...finalCollection,
|
||||
},
|
||||
isRootCollection: props.editingProperties.isRootCollection,
|
||||
}
|
||||
emit("set-collection-properties", collection as EditingProperties)
|
||||
|
||||
const data = props.emitWithFullCollection
|
||||
? collection
|
||||
: { ...finalCollection, collIndexPath: path.value }
|
||||
emit("set-collection-properties", data as EditingProperties)
|
||||
|
||||
persistenceService.removeLocalConfig("unsaved_collection_properties")
|
||||
}
|
||||
|
||||
|
||||
@@ -20,19 +20,25 @@
|
||||
<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>
|
||||
@@ -65,40 +71,31 @@ import {
|
||||
} from "@hoppscotch/data"
|
||||
import { computedWithControl } from "@vueuse/core"
|
||||
import { useService } from "dioc/vue"
|
||||
import * as TE from "fp-ts/TaskEither"
|
||||
import { pipe } from "fp-ts/function"
|
||||
import * as E from "fp-ts/Either"
|
||||
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,
|
||||
editGraphqlRequest,
|
||||
editRESTRequest,
|
||||
saveGraphqlRequestAs,
|
||||
saveRESTRequestAs,
|
||||
} from "~/newstore/collections"
|
||||
import { platform } from "~/platform"
|
||||
import { cascadeParentCollectionForHeaderAuth } from "~/newstore/collections"
|
||||
import { NewWorkspaceService } from "~/services/new-workspace"
|
||||
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 CollectionType =
|
||||
| {
|
||||
type: "team-collections"
|
||||
selectedTeam: TeamWorkspace
|
||||
}
|
||||
| { type: "my-collections"; selectedTeam: undefined }
|
||||
// type SelectedTeam = GetMyTeamsQuery["myTeams"][number] | undefined
|
||||
|
||||
// type CollectionType =
|
||||
// | {
|
||||
// type: "team-collections"
|
||||
// selectedTeam: SelectedTeam
|
||||
// }
|
||||
// | { type: "my-collections"; selectedTeam: undefined }
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -166,10 +163,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)
|
||||
|
||||
@@ -190,13 +187,14 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
const updateTeam = (newTeam: TeamWorkspace) => {
|
||||
collectionsType.value.selectedTeam = newTeam
|
||||
}
|
||||
// TODO: To be removed
|
||||
// const updateTeam = (newTeam: SelectedTeam) => {
|
||||
// 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
|
||||
@@ -212,104 +210,109 @@ const saveRequestAs = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
const requestUpdated =
|
||||
const updatedRequest =
|
||||
props.mode === "rest"
|
||||
? cloneDeep(RESTTabs.currentActiveTab.value.document.request)
|
||||
: cloneDeep(GQLTabs.currentActiveTab.value.document.request)
|
||||
|
||||
requestUpdated.name = requestName.value
|
||||
updatedRequest.name = requestName.value
|
||||
|
||||
if (picked.value.pickedType === "my-collection") {
|
||||
if (!isHoppRESTRequest(requestUpdated))
|
||||
if (!workspaceService.activeWorkspaceHandle.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
picked.value.pickedType === "my-collection" ||
|
||||
picked.value.pickedType === "my-folder"
|
||||
) {
|
||||
if (!isHoppRESTRequest(updatedRequest))
|
||||
throw new Error("requestUpdated is not a REST Request")
|
||||
|
||||
const insertionIndex = saveRESTRequestAs(
|
||||
`${picked.value.collectionIndex}`,
|
||||
requestUpdated
|
||||
const collectionPathIndex =
|
||||
picked.value.pickedType === "my-collection"
|
||||
? picked.value.collectionIndex.toString()
|
||||
: picked.value.folderPath
|
||||
|
||||
const collectionHandleResult = await workspaceService.getCollectionHandle(
|
||||
workspaceService.activeWorkspaceHandle.value,
|
||||
collectionPathIndex
|
||||
)
|
||||
|
||||
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: requestUpdated,
|
||||
request: updatedRequest,
|
||||
isDirty: false,
|
||||
saveContext: {
|
||||
originLocation: "user-collection",
|
||||
folderPath: `${picked.value.collectionIndex}`,
|
||||
requestIndex: insertionIndex,
|
||||
originLocation: "workspace-user-collection",
|
||||
requestHandle,
|
||||
},
|
||||
}
|
||||
|
||||
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-folder") {
|
||||
if (!isHoppRESTRequest(requestUpdated))
|
||||
throw new Error("requestUpdated is not a REST Request")
|
||||
|
||||
const insertionIndex = saveRESTRequestAs(
|
||||
picked.value.folderPath,
|
||||
requestUpdated
|
||||
)
|
||||
|
||||
RESTTabs.currentActiveTab.value.document = {
|
||||
request: requestUpdated,
|
||||
isDirty: false,
|
||||
saveContext: {
|
||||
originLocation: "user-collection",
|
||||
folderPath: picked.value.folderPath,
|
||||
requestIndex: insertionIndex,
|
||||
},
|
||||
}
|
||||
|
||||
const { auth, headers } = cascadeParentCollectionForHeaderAuth(
|
||||
picked.value.folderPath,
|
||||
"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(requestUpdated))
|
||||
if (!isHoppRESTRequest(updatedRequest))
|
||||
throw new Error("requestUpdated is not a REST Request")
|
||||
|
||||
editRESTRequest(
|
||||
picked.value.folderPath,
|
||||
picked.value.requestIndex,
|
||||
requestUpdated
|
||||
const requestHandleResult = await workspaceService.getRequestHandle(
|
||||
workspaceService.activeWorkspaceHandle.value,
|
||||
`${picked.value.folderPath}/${picked.value.requestIndex.toString()}`
|
||||
)
|
||||
|
||||
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: requestUpdated,
|
||||
request: updatedRequest,
|
||||
isDirty: false,
|
||||
saveContext: {
|
||||
originLocation: "user-collection",
|
||||
folderPath: picked.value.folderPath,
|
||||
requestIndex: picked.value.requestIndex,
|
||||
originLocation: "workspace-user-collection",
|
||||
requestHandle,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -323,152 +326,147 @@ const saveRequestAs = async () => {
|
||||
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()
|
||||
// }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -476,50 +474,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.teamID,
|
||||
}
|
||||
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.id,
|
||||
// }
|
||||
// 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")}`)
|
||||
@@ -534,24 +532,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>
|
||||
|
||||
@@ -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 { initializeDownloadCollection } from "~/helpers/import-export/export"
|
||||
import { initializeDownloadFile } 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: () => {
|
||||
action: async () => {
|
||||
if (!gqlCollections.value.length) {
|
||||
return toast.error(t("error.no_collections_to_export"))
|
||||
}
|
||||
|
||||
const message = initializeDownloadCollection(
|
||||
const message = await initializeDownloadFile(
|
||||
gqlCollectionsExporter(gqlCollections.value),
|
||||
"GQLCollections"
|
||||
)
|
||||
|
||||
@@ -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 { initializeDownloadCollection } from "~/helpers/import-export/export"
|
||||
import { initializeDownloadFile } 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: () => {
|
||||
action: async () => {
|
||||
if (!environmentJson.value.length) {
|
||||
return toast.error(t("error.no_environments_to_export"))
|
||||
}
|
||||
|
||||
const message = initializeDownloadCollection(
|
||||
const message = await initializeDownloadFile(
|
||||
environmentsExporter(environmentJson.value),
|
||||
"Environments"
|
||||
)
|
||||
|
||||
@@ -236,16 +236,28 @@ 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, ref, onUnmounted } from "vue"
|
||||
import { Ref, computed, onUnmounted, ref } from "vue"
|
||||
import { runRESTRequest$ } from "~/helpers/RequestRunner"
|
||||
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 { runRESTRequest$ } from "~/helpers/RequestRunner"
|
||||
import { getDefaultRESTRequest } from "~/helpers/rest/default"
|
||||
import { HoppRESTDocument } from "~/helpers/rest/document"
|
||||
import { getMethodLabelColor } from "~/helpers/rest/labelColoring"
|
||||
import { HoppRESTResponse } from "~/helpers/types/HoppRESTResponse"
|
||||
import { editRESTRequest } from "~/newstore/collections"
|
||||
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 IconChevronDown from "~icons/lucide/chevron-down"
|
||||
import IconCode2 from "~icons/lucide/code-2"
|
||||
import IconFileCode from "~icons/lucide/file-code"
|
||||
@@ -253,21 +265,10 @@ 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",
|
||||
@@ -506,34 +507,61 @@ const cycleDownMethod = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const saveRequest = () => {
|
||||
const saveCtx = tab.value.document.saveContext
|
||||
const saveRequest = async () => {
|
||||
const { saveContext } = tab.value.document
|
||||
|
||||
if (!saveCtx) {
|
||||
if (!saveContext) {
|
||||
showSaveRequestModal.value = true
|
||||
return
|
||||
}
|
||||
if (saveCtx.originLocation === "user-collection") {
|
||||
const req = tab.value.document.request
|
||||
|
||||
try {
|
||||
editRESTRequest(saveCtx.folderPath, saveCtx.requestIndex, req)
|
||||
if (saveContext.originLocation === "workspace-user-collection") {
|
||||
const updatedRequest = tab.value.document.request
|
||||
|
||||
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()
|
||||
if (
|
||||
!newWorkspaceService.activeWorkspaceHandle.value ||
|
||||
!saveContext.requestHandle
|
||||
) {
|
||||
return
|
||||
}
|
||||
} else if (saveCtx.originLocation === "team-collection") {
|
||||
|
||||
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") {
|
||||
const req = tab.value.document.request
|
||||
|
||||
// TODO: handle error case (NOTE: overwriteRequestTeams is async)
|
||||
@@ -546,7 +574,7 @@ const saveRequest = () => {
|
||||
})
|
||||
|
||||
runMutation(UpdateRequestDocument, {
|
||||
requestID: saveCtx.requestID,
|
||||
requestID: saveContext.requestID,
|
||||
data: {
|
||||
title: req.name,
|
||||
request: JSON.stringify(req),
|
||||
|
||||
@@ -17,8 +17,7 @@
|
||||
<script setup lang="ts">
|
||||
import { watch } from "vue"
|
||||
import { useVModel } from "@vueuse/core"
|
||||
import { cloneDeep } from "lodash-es"
|
||||
import { isEqualHoppRESTRequest } from "@hoppscotch/data"
|
||||
import { cloneDeep, isEqual } from "lodash-es"
|
||||
import { HoppTab } from "~/services/tab"
|
||||
import { HoppRESTDocument } from "~/helpers/rest/document"
|
||||
|
||||
@@ -32,15 +31,42 @@ 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.isDirty &&
|
||||
!isEqualHoppRESTRequest(oldRequest, updatedValue)
|
||||
tab.value.document.saveContext?.originLocation ===
|
||||
"workspace-user-collection"
|
||||
) {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
:icon="IconFolder"
|
||||
:label="`${t('tab.collections')}`"
|
||||
>
|
||||
<Collections />
|
||||
<!-- <Collections /> -->
|
||||
<NewCollections :platform="'rest'" />
|
||||
</HoppSmartTab>
|
||||
<HoppSmartTab
|
||||
:id="'env'"
|
||||
@@ -37,12 +38,13 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import IconClock from "~icons/lucide/clock"
|
||||
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"
|
||||
import { ref } from "vue"
|
||||
|
||||
import IconClock from "~icons/lucide/clock"
|
||||
import IconFolder from "~icons/lucide/folder"
|
||||
import IconLayers from "~icons/lucide/layers"
|
||||
import IconShare2 from "~icons/lucide/share-2"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
|
||||
@@ -106,15 +106,15 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue"
|
||||
import { TippyComponent } from "vue-tippy"
|
||||
import { getMethodLabelColorClassOf } from "~/helpers/rest/labelColoring"
|
||||
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 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()
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<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>
|
||||
@@ -0,0 +1,450 @@
|
||||
<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>
|
||||
@@ -0,0 +1,334 @@
|
||||
<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>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -61,7 +61,7 @@
|
||||
<div
|
||||
v-for="(member, index) in membersList"
|
||||
:key="`member-${index}`"
|
||||
class="flex"
|
||||
class="flex divide-x divide-dividerLight"
|
||||
>
|
||||
<input
|
||||
class="flex flex-1 bg-transparent px-4 py-2"
|
||||
|
||||
@@ -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">
|
||||
{{ currentWorkspace }}
|
||||
{{ workspaceName ?? t("workspace.no_workspace") }}
|
||||
</span>
|
||||
<icon-lucide-chevron-right v-if="section" class="mx-2" />
|
||||
{{ section }}
|
||||
@@ -14,29 +14,24 @@
|
||||
import { computed } from "vue"
|
||||
import { useI18n } from "~/composables/i18n"
|
||||
import { useService } from "dioc/vue"
|
||||
import { WorkspaceService } from "~/services/workspace.service"
|
||||
import { NewWorkspaceService } from "~/services/new-workspace"
|
||||
|
||||
const props = defineProps<{
|
||||
defineProps<{
|
||||
section?: string
|
||||
isOnlyPersonal?: boolean
|
||||
}>()
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
const workspaceService = useService(WorkspaceService)
|
||||
const workspace = workspaceService.currentWorkspace
|
||||
const workspaceService = useService(NewWorkspaceService)
|
||||
const activeWorkspaceHandle = workspaceService.activeWorkspaceHandle
|
||||
|
||||
const currentWorkspace = computed(() => {
|
||||
if (props.isOnlyPersonal || workspace.value.type === "personal") {
|
||||
return t("workspace.personal")
|
||||
}
|
||||
return teamWorkspaceName.value
|
||||
})
|
||||
const workspaceName = computed(() => {
|
||||
const activeWorkspaceHandleRef = activeWorkspaceHandle.value?.get()
|
||||
|
||||
const teamWorkspaceName = computed(() => {
|
||||
if (workspace.value.type === "team" && workspace.value.teamName) {
|
||||
return workspace.value.teamName
|
||||
if (activeWorkspaceHandleRef?.value.type === "ok") {
|
||||
return activeWorkspaceHandleRef.value.data.name
|
||||
}
|
||||
return `${t("workspace.team")}`
|
||||
|
||||
return undefined
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<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>
|
||||
@@ -1,198 +1,36 @@
|
||||
<template>
|
||||
<div ref="rootEl">
|
||||
<div class="flex flex-col">
|
||||
<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"
|
||||
/>
|
||||
<div
|
||||
v-for="(selectorComponent, index) in workspaceSelectorComponents"
|
||||
:key="index"
|
||||
class="flex flex-col"
|
||||
>
|
||||
<component :is="selectorComponent" />
|
||||
<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 { useElementVisibility, useIntervalFn } from "@vueuse/core"
|
||||
import { NewWorkspaceService } from "~/services/new-workspace"
|
||||
import { TestWorkspaceProviderService } from "~/services/new-workspace/providers/test.workspace"
|
||||
|
||||
const t = useI18n()
|
||||
const colorMode = useColorMode()
|
||||
useService(TestWorkspaceProviderService)
|
||||
|
||||
const showModalAdd = ref(false)
|
||||
const newWorkspaceService = useService(NewWorkspaceService)
|
||||
const workspaceSelectorComponents =
|
||||
newWorkspaceService.workspaceSelectorComponents
|
||||
|
||||
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)
|
||||
})
|
||||
// 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)
|
||||
// })
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<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>
|
||||
@@ -0,0 +1,92 @@
|
||||
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
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
import { HoppCollection } from "@hoppscotch/data"
|
||||
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"
|
||||
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"
|
||||
|
||||
/**
|
||||
* Resolve save context on reorder
|
||||
@@ -18,15 +21,12 @@ import { GQLTabService } from "~/services/tab/graphql"
|
||||
* @returns
|
||||
*/
|
||||
|
||||
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"
|
||||
) {
|
||||
export function resolveSaveContextOnCollectionReorder(payload: {
|
||||
lastIndex: number
|
||||
newIndex: number
|
||||
folderPath: string
|
||||
length?: number // better way to do this? now it could be undefined
|
||||
}) {
|
||||
const { lastIndex, folderPath, length } = payload
|
||||
let { newIndex } = payload
|
||||
|
||||
@@ -41,12 +41,6 @@ export function resolveSaveContextOnCollectionReorder(
|
||||
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
|
||||
@@ -62,10 +56,27 @@ export function resolveSaveContextOnCollectionReorder(
|
||||
const tabService = getService(RESTTabService)
|
||||
|
||||
const tabs = tabService.getTabsRefTo((tab) => {
|
||||
return (
|
||||
tab.document.saveContext?.originLocation === "user-collection" &&
|
||||
affectedPaths.has(tab.document.saveContext.folderPath)
|
||||
)
|
||||
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)
|
||||
})
|
||||
|
||||
for (const tab of tabs) {
|
||||
@@ -75,6 +86,34 @@ export function resolveSaveContextOnCollectionReorder(
|
||||
)!
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,25 +125,63 @@ export function resolveSaveContextOnCollectionReorder(
|
||||
*/
|
||||
|
||||
export function updateSaveContextForAffectedRequests(
|
||||
oldFolderPath: string,
|
||||
newFolderPath: string
|
||||
draggedCollectionIndex: string,
|
||||
destinationCollectionIndex: string
|
||||
) {
|
||||
const tabService = getService(RESTTabService)
|
||||
const tabs = tabService.getTabsRefTo((tab) => {
|
||||
return (
|
||||
tab.document.saveContext?.originLocation === "user-collection" &&
|
||||
tab.document.saveContext.folderPath.startsWith(oldFolderPath)
|
||||
)
|
||||
})
|
||||
|
||||
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
|
||||
),
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -166,6 +243,33 @@ 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,
|
||||
@@ -177,22 +281,17 @@ export function updateInheritedPropertiesForAffectedRequests(
|
||||
const effectedTabs = tabService.getTabsRefTo((tab) => {
|
||||
const saveContext = tab.document.saveContext
|
||||
|
||||
const saveContextPath =
|
||||
saveContext?.originLocation === "team-collection"
|
||||
? saveContext.collectionID
|
||||
: saveContext?.folderPath
|
||||
|
||||
return saveContextPath?.startsWith(path) ?? false
|
||||
const collectionID = getSaveContextCollectionID(saveContext)
|
||||
return collectionID?.startsWith(path) ?? false
|
||||
})
|
||||
|
||||
effectedTabs.map((tab) => {
|
||||
const inheritedParentID =
|
||||
tab.value.document.inheritedProperties?.auth.parentID
|
||||
|
||||
const contextPath =
|
||||
tab.value.document.saveContext?.originLocation === "team-collection"
|
||||
? tab.value.document.saveContext.collectionID
|
||||
: tab.value.document.saveContext?.folderPath
|
||||
const contextPath = getSaveContextCollectionID(
|
||||
tab.value.document.saveContext
|
||||
)
|
||||
|
||||
const effectedPath = folderPathCloseToSaveContext(
|
||||
inheritedParentID,
|
||||
@@ -227,21 +326,6 @@ 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
|
||||
|
||||
@@ -3,9 +3,10 @@ import {
|
||||
HoppGQLRequest,
|
||||
HoppRESTRequest,
|
||||
} from "@hoppscotch/data"
|
||||
import { getAffectedIndexes } from "./affectedIndex"
|
||||
import { RESTTabService } from "~/services/tab/rest"
|
||||
|
||||
import { getService } from "~/modules/dioc"
|
||||
import { RESTTabService } from "~/services/tab/rest"
|
||||
import { getAffectedIndexes } from "./affectedIndex"
|
||||
|
||||
/**
|
||||
* Resolve save context on reorder
|
||||
@@ -29,30 +30,76 @@ 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 affectedIndexes = getAffectedIndexes(
|
||||
const affectedIndices = getAffectedIndexes(
|
||||
lastIndex,
|
||||
newIndex === -1 ? length! : newIndex
|
||||
)
|
||||
|
||||
// if (newIndex === -1) remove it from the map because it will be deleted
|
||||
if (newIndex === -1) affectedIndexes.delete(lastIndex)
|
||||
if (newIndex === -1) affectedIndices.delete(lastIndex)
|
||||
|
||||
const tabService = getService(RESTTabService)
|
||||
const tabs = tabService.getTabsRefTo((tab) => {
|
||||
return (
|
||||
tab.document.saveContext?.originLocation === "user-collection" &&
|
||||
tab.document.saveContext.folderPath === folderPath &&
|
||||
affectedIndexes.has(tab.document.saveContext.requestIndex)
|
||||
)
|
||||
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)
|
||||
})
|
||||
|
||||
for (const tab of tabs) {
|
||||
if (tab.value.document.saveContext?.originLocation === "user-collection") {
|
||||
const newIndex = affectedIndexes.get(
|
||||
const newIndex = affectedIndices.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("/")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +114,7 @@ export function getRequestsByPath(
|
||||
|
||||
if (pathArray.length === 1) {
|
||||
const latestVersionedRequests = currentCollection.requests.filter(
|
||||
(req): req is HoppRESTRequest => req.v === "3"
|
||||
(req): req is HoppRESTRequest => req.v === "4"
|
||||
)
|
||||
|
||||
return latestVersionedRequests
|
||||
@@ -78,7 +125,7 @@ export function getRequestsByPath(
|
||||
}
|
||||
|
||||
const latestVersionedRequests = currentCollection.requests.filter(
|
||||
(req): req is HoppRESTRequest => req.v === "3"
|
||||
(req): req is HoppRESTRequest => req.v === "4"
|
||||
)
|
||||
|
||||
return latestVersionedRequests
|
||||
|
||||
@@ -1,32 +1,31 @@
|
||||
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 initializeDownloadCollection = (
|
||||
export const initializeDownloadFile = async (
|
||||
collectionJSON: string,
|
||||
name: string | null
|
||||
) => {
|
||||
const file = new Blob([collectionJSON], { type: "application/json" })
|
||||
const a = document.createElement("a")
|
||||
const url = URL.createObjectURL(file)
|
||||
a.href = url
|
||||
const result = await platform.io.saveFileWithDialog({
|
||||
data: collectionJSON,
|
||||
contentType: "application/json",
|
||||
suggestedFilename: `${name ?? "collection"}.json`,
|
||||
filters: [
|
||||
{
|
||||
name: "Hoppscotch Collection/Environment JSON file",
|
||||
extensions: ["json"],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
if (name) {
|
||||
a.download = `${name}.json`
|
||||
} else {
|
||||
a.download = `${url.split("/").pop()!.split("#")[0].split("?")[0]}.json`
|
||||
if (result.type === "unknown" || result.type === "saved") {
|
||||
return E.right("state.download_started")
|
||||
}
|
||||
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}, 1000)
|
||||
|
||||
return E.right("state.download_started")
|
||||
return E.left("state.download_failed")
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import { HoppCollection } from "@hoppscotch/data"
|
||||
|
||||
export const myCollectionsExporter = (myCollections: HoppCollection[]) => {
|
||||
return JSON.stringify(myCollections, null, 2)
|
||||
}
|
||||
@@ -3,8 +3,37 @@ 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
|
||||
|
||||
@@ -17,3 +17,24 @@ 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
|
||||
>
|
||||
|
||||
@@ -20,28 +20,29 @@ export type Picked =
|
||||
pickedType: "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
|
||||
}
|
||||
// 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
|
||||
// }
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
export function shortDateTime(
|
||||
date: string | number | Date,
|
||||
includeTime: boolean = true
|
||||
) {
|
||||
export function shortDateTime(date: string | number | Date) {
|
||||
return new Date(date).toLocaleString("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
...(includeTime
|
||||
? {
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
second: "numeric",
|
||||
}
|
||||
: {}),
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
second: "numeric",
|
||||
})
|
||||
}
|
||||
|
||||
17
packages/hoppscotch-common/src/helpers/utils/lazy.ts
Normal file
17
packages/hoppscotch-common/src/helpers/utils/lazy.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* 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!
|
||||
}
|
||||
}
|
||||
@@ -3,36 +3,40 @@ 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)
|
||||
|
||||
// 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))
|
||||
|
||||
// Some basic work that needs to be done before module inits even
|
||||
initBackendGQLClient()
|
||||
initializeApp()
|
||||
// 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(
|
||||
|
||||
@@ -319,6 +319,7 @@ 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
|
||||
@@ -690,17 +691,11 @@ const restCollectionDispatchers = defineDispatchers({
|
||||
|
||||
// if the destination is null, we are moving to the end of the list
|
||||
if (destinationRequestIndex === null) {
|
||||
// move to the end of the list
|
||||
// TODO: Verify if this can be safely removed
|
||||
targetLocation.requests.push(
|
||||
targetLocation.requests.splice(requestIndex, 1)[0]
|
||||
)
|
||||
|
||||
resolveSaveContextOnRequestReorder({
|
||||
lastIndex: requestIndex,
|
||||
newIndex: targetLocation.requests.length,
|
||||
folderPath: destinationCollectionPath,
|
||||
})
|
||||
|
||||
return {
|
||||
state: newState,
|
||||
}
|
||||
@@ -708,12 +703,6 @@ const restCollectionDispatchers = defineDispatchers({
|
||||
|
||||
reorderItems(targetLocation.requests, requestIndex, destinationRequestIndex)
|
||||
|
||||
resolveSaveContextOnRequestReorder({
|
||||
lastIndex: requestIndex,
|
||||
newIndex: destinationRequestIndex,
|
||||
folderPath: destinationCollectionPath,
|
||||
})
|
||||
|
||||
return {
|
||||
state: newState,
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
</template>
|
||||
<template #suffix>
|
||||
<span
|
||||
v-if="tab.document.isDirty"
|
||||
v-if="getTabDirtyStatus(tab)"
|
||||
class="flex w-4 items-center justify-center text-secondary group-hover:hidden"
|
||||
>
|
||||
<svg
|
||||
@@ -64,6 +64,13 @@
|
||||
@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')"
|
||||
@@ -71,36 +78,6 @@
|
||||
@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"
|
||||
@@ -118,24 +95,24 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, onMounted } from "vue"
|
||||
import { safelyExtractRESTRequest } from "@hoppscotch/data"
|
||||
import { translateExtURLParams } from "~/helpers/RESTExtURLParams"
|
||||
import { useRoute } from "vue-router"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { getDefaultRESTRequest } from "~/helpers/rest/default"
|
||||
import { defineActionHandler, invokeAction } from "~/helpers/actions"
|
||||
import { platform } from "~/platform"
|
||||
import { useReadonlyStream } from "~/composables/stream"
|
||||
import { safelyExtractRESTRequest } from "@hoppscotch/data"
|
||||
import { useService } from "dioc/vue"
|
||||
import { InspectionService } from "~/services/inspection"
|
||||
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 { cloneDeep } from "lodash-es"
|
||||
import { RESTTabService } from "~/services/tab/rest"
|
||||
import { HoppTab } from "~/services/tab"
|
||||
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 { getDefaultRESTRequest } from "~/helpers/rest/default"
|
||||
import { HoppRESTDocument } from "~/helpers/rest/document"
|
||||
import { platform } from "~/platform"
|
||||
import { InspectionService } from "~/services/inspection"
|
||||
import { EnvironmentInspectorService } from "~/services/inspection/inspectors/environment.inspector"
|
||||
import { HeaderInspectorService } from "~/services/inspection/inspectors/header.inspector"
|
||||
import { ResponseInspectorService } from "~/services/inspection/inspectors/response.inspector"
|
||||
import { HoppTab } from "~/services/tab"
|
||||
import { RESTTabService } from "~/services/tab/rest"
|
||||
|
||||
const savingRequest = ref(false)
|
||||
const confirmingCloseForTabID = ref<string | null>(null)
|
||||
@@ -216,7 +193,7 @@ const inspectionService = useService(InspectionService)
|
||||
const removeTab = (tabID: string) => {
|
||||
const tabState = tabs.getTabRef(tabID).value
|
||||
|
||||
if (tabState.document.isDirty) {
|
||||
if (getTabDirtyStatus(tabState)) {
|
||||
confirmingCloseForTabID.value = tabID
|
||||
} else {
|
||||
tabs.closeTab(tabState.id)
|
||||
@@ -225,8 +202,10 @@ 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
|
||||
|
||||
@@ -291,15 +270,24 @@ const onCloseConfirmSaveTab = () => {
|
||||
* Called when the user confirms they want to save the tab
|
||||
*/
|
||||
const onResolveConfirmSaveTab = () => {
|
||||
if (tabs.currentActiveTab.value.document.saveContext) {
|
||||
invokeAction("request.save")
|
||||
const { saveContext } = tabs.currentActiveTab.value.document
|
||||
|
||||
if (confirmingCloseForTabID.value) {
|
||||
tabs.closeTab(confirmingCloseForTabID.value)
|
||||
confirmingCloseForTabID.value = null
|
||||
}
|
||||
} else {
|
||||
savingRequest.value = true
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -327,6 +315,17 @@ 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 = {
|
||||
|
||||
@@ -182,79 +182,38 @@
|
||||
<ProfileUserDelete />
|
||||
</div>
|
||||
</HoppSmartTab>
|
||||
|
||||
<HoppSmartTab :id="'teams'" :label="t('team.title')">
|
||||
<Teams :modal="false" class="p-4" />
|
||||
</HoppSmartTab>
|
||||
|
||||
<HoppSmartTab id="tokens" label="Tokens" class="space-y-4">
|
||||
<AccessTokensOverview
|
||||
@show-access-tokens-generate-modal="
|
||||
showAccessTokensGenerateModal = true
|
||||
"
|
||||
/>
|
||||
|
||||
<AccessTokensList
|
||||
:access-tokens="accessTokens"
|
||||
:has-more-tokens="hasMoreTokens"
|
||||
:tokens-list-loading="tokensListLoading"
|
||||
@delete-access-token="showDeleteAccessTokenConfirmation"
|
||||
@fetch-more-tokens="fetchAccessTokens"
|
||||
/>
|
||||
</HoppSmartTab>
|
||||
</HoppSmartTabs>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AccessTokensGenerateModal
|
||||
v-if="showAccessTokensGenerateModal"
|
||||
:access-token="accessToken"
|
||||
:token-generate-action-loading="tokenGenerateActionLoading"
|
||||
@generate-access-token="generateAccessToken"
|
||||
@hide-modal="hideAccessTokenGenerateModal"
|
||||
/>
|
||||
|
||||
<HoppSmartConfirmModal
|
||||
:show="confirmDeleteAccessToken"
|
||||
:loading-state="tokenDeleteActionLoading"
|
||||
:title="`Are you sure you want to delete the access token ${tokenToDelete?.label}?`"
|
||||
@hide-modal="confirmDeleteAccessToken = false"
|
||||
@resolve="() => deleteAccessToken()"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import axios from "axios"
|
||||
import * as E from "fp-ts/Either"
|
||||
import { Ref, computed, onMounted, ref, watchEffect } from "vue"
|
||||
import { ref, watchEffect, computed } from "vue"
|
||||
|
||||
import { platform } from "~/platform"
|
||||
|
||||
import { usePageHead } from "@composables/head"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useSetting } from "@composables/settings"
|
||||
import { useReadonlyStream } from "@composables/stream"
|
||||
import { useColorMode } from "@composables/theming"
|
||||
import { useToast } from "@composables/toast"
|
||||
import { invokeAction } from "~/helpers/actions"
|
||||
import { useReadonlyStream } from "@composables/stream"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useToast } from "@composables/toast"
|
||||
import { useSetting } from "@composables/settings"
|
||||
import { useColorMode } from "@composables/theming"
|
||||
import { usePageHead } from "@composables/head"
|
||||
|
||||
import { toggleSetting } from "~/newstore/settings"
|
||||
|
||||
import IconSettings from "~icons/lucide/settings"
|
||||
import IconVerified from "~icons/lucide/verified"
|
||||
import IconSettings from "~icons/lucide/settings"
|
||||
|
||||
import * as E from "fp-ts/Either"
|
||||
|
||||
type ProfileTabs = "sync" | "teams"
|
||||
|
||||
export type AccessToken = {
|
||||
id: string
|
||||
label: string
|
||||
createdOn: Date
|
||||
lastUsedOn: Date
|
||||
expiresOn: Date | null
|
||||
}
|
||||
|
||||
const selectedProfileTab = ref<ProfileTabs>("sync")
|
||||
|
||||
const t = useI18n()
|
||||
@@ -265,12 +224,6 @@ usePageHead({
|
||||
title: computed(() => t("navigation.profile")),
|
||||
})
|
||||
|
||||
const accessTokens: Ref<AccessToken[]> = ref([])
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchAccessTokens()
|
||||
})
|
||||
|
||||
const SYNC_COLLECTIONS = useSetting("syncCollections")
|
||||
const SYNC_ENVIRONMENTS = useSetting("syncEnvironments")
|
||||
const SYNC_HISTORY = useSetting("syncHistory")
|
||||
@@ -283,24 +236,6 @@ const probableUser = useReadonlyStream(
|
||||
platform.auth.getProbableUser()
|
||||
)
|
||||
|
||||
const confirmDeleteAccessToken = ref(false)
|
||||
const hasMoreTokens = ref(true)
|
||||
const showAccessTokensGenerateModal = ref(false)
|
||||
const tokenDeleteActionLoading = ref(false)
|
||||
const tokenGenerateActionLoading = ref(false)
|
||||
const tokensListLoading = ref(false)
|
||||
|
||||
const accessToken: Ref<string | null> = ref(null)
|
||||
const tokenToDelete = ref<{ id: string; label: string } | null>(null)
|
||||
|
||||
const limit = 10
|
||||
let offset = 0
|
||||
|
||||
const accessTokenEndpointMetadata = {
|
||||
axiosPlatformConfig: platform.auth.axiosPlatformConfig?.() ?? {},
|
||||
endpointPrefix: `${import.meta.env.VITE_BACKEND_API_URL}/access-tokens`,
|
||||
}
|
||||
|
||||
const loadingCurrentUser = computed(() => {
|
||||
if (!probableUser.value) return false
|
||||
else if (!currentUser.value) return true
|
||||
@@ -370,116 +305,4 @@ const sendEmailVerification = () => {
|
||||
verifyingEmailAddress.value = false
|
||||
})
|
||||
}
|
||||
|
||||
const fetchAccessTokens = async () => {
|
||||
tokensListLoading.value = true
|
||||
|
||||
const { axiosPlatformConfig, endpointPrefix } = accessTokenEndpointMetadata
|
||||
|
||||
const endpoint = `${endpointPrefix}/list?offset=${offset}&limit=${limit}`
|
||||
|
||||
try {
|
||||
const { data } = await axios.get(endpoint, axiosPlatformConfig)
|
||||
|
||||
accessTokens.value.push(...data)
|
||||
|
||||
if (data.length > 0) {
|
||||
offset += data.length
|
||||
}
|
||||
|
||||
hasMoreTokens.value = data.length === limit
|
||||
} catch (err) {
|
||||
toast.error("Something went wrong while fetching the list of tokens")
|
||||
} finally {
|
||||
tokensListLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const generateAccessToken = async ({
|
||||
label,
|
||||
expiryInDays,
|
||||
}: {
|
||||
label: string
|
||||
expiryInDays: number | null
|
||||
}) => {
|
||||
tokenGenerateActionLoading.value = true
|
||||
|
||||
const { axiosPlatformConfig, endpointPrefix } = accessTokenEndpointMetadata
|
||||
|
||||
const endpoint = `${endpointPrefix}/create`
|
||||
|
||||
const body = {
|
||||
label,
|
||||
expiryInDays,
|
||||
}
|
||||
|
||||
try {
|
||||
const { data }: { data: { token: string; info: AccessToken } } =
|
||||
await axios.post(endpoint, body, axiosPlatformConfig)
|
||||
|
||||
accessTokens.value.unshift(data.info)
|
||||
accessToken.value = data.token
|
||||
|
||||
// Incrementing the offset value by 1 to account for the newly generated token
|
||||
offset += 1
|
||||
} catch (err) {
|
||||
toast.error(`Something went wrong while generating the access token`)
|
||||
} finally {
|
||||
tokenGenerateActionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const deleteAccessToken = async () => {
|
||||
if (tokenToDelete.value === null) {
|
||||
toast.error("error.something_went_wrong")
|
||||
return
|
||||
}
|
||||
|
||||
const { id: tokenIdToDelete } = tokenToDelete.value
|
||||
|
||||
tokenDeleteActionLoading.value = true
|
||||
|
||||
const { axiosPlatformConfig, endpointPrefix } = accessTokenEndpointMetadata
|
||||
|
||||
const endpoint = `${endpointPrefix}/revoke?id=${tokenIdToDelete}`
|
||||
|
||||
try {
|
||||
await axios.delete(endpoint, axiosPlatformConfig)
|
||||
|
||||
accessTokens.value = accessTokens.value.filter(
|
||||
(token) => token.id !== tokenIdToDelete
|
||||
)
|
||||
|
||||
// Decreasing the offset value by 1 to account for the deleted token
|
||||
offset = offset > 0 ? offset - 1 : offset
|
||||
} catch (err) {
|
||||
toast.error("Something went wrong while deleting the access token")
|
||||
} finally {
|
||||
tokenDeleteActionLoading.value = false
|
||||
confirmDeleteAccessToken.value = false
|
||||
|
||||
tokenToDelete.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const hideAccessTokenGenerateModal = () => {
|
||||
// Reset the reactive state variable holding access token value and hide the modal
|
||||
accessToken.value = null
|
||||
showAccessTokensGenerateModal.value = false
|
||||
}
|
||||
|
||||
const showDeleteAccessTokenConfirmation = ({
|
||||
tokenId,
|
||||
tokenLabel,
|
||||
}: {
|
||||
tokenId: string
|
||||
tokenLabel: string
|
||||
}) => {
|
||||
confirmDeleteAccessToken.value = true
|
||||
|
||||
tokenToDelete.value = {
|
||||
id: tokenId,
|
||||
label: tokenLabel,
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -13,15 +13,17 @@ export const browserIODef: IOPlatformDef = {
|
||||
const url = URL.createObjectURL(file)
|
||||
|
||||
a.href = url
|
||||
a.download = pipe(
|
||||
url,
|
||||
S.split("/"),
|
||||
RNEA.last,
|
||||
S.split("#"),
|
||||
RNEA.head,
|
||||
S.split("?"),
|
||||
RNEA.head
|
||||
)
|
||||
a.download =
|
||||
opts.suggestedFilename ??
|
||||
pipe(
|
||||
url,
|
||||
S.split("/"),
|
||||
RNEA.last,
|
||||
S.split("#"),
|
||||
RNEA.head,
|
||||
S.split("?"),
|
||||
RNEA.head
|
||||
)
|
||||
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
|
||||
@@ -121,6 +121,11 @@ 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],
|
||||
() => {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
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>>
|
||||
@@ -0,0 +1,48 @@
|
||||
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
|
||||
)
|
||||
}
|
||||
807
packages/hoppscotch-common/src/services/new-workspace/index.ts
Normal file
807
packages/hoppscotch-common/src/services/new-workspace/index.ts
Normal file
@@ -0,0 +1,807 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
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>>
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,52 @@
|
||||
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
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,309 @@
|
||||
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,
|
||||
}))
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
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
|
||||
}
|
||||
@@ -580,8 +580,6 @@ describe("PersistenceService", () => {
|
||||
|
||||
invokeSetupLocalPersistence()
|
||||
|
||||
// toastErrorFn = vi.fn()
|
||||
|
||||
expect(getItemSpy).toHaveBeenCalledWith(settingsKey)
|
||||
|
||||
expect(toastErrorFn).not.toHaveBeenCalledWith(settingsKey)
|
||||
|
||||
@@ -492,6 +492,15 @@ 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"),
|
||||
|
||||
@@ -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,17 +30,18 @@ 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: {
|
||||
...tab.document,
|
||||
...this.getPersistedDocument(tab.document),
|
||||
response: null,
|
||||
},
|
||||
}
|
||||
}),
|
||||
}))
|
||||
|
||||
public getTabRefWithSaveContext(ctx: HoppRESTSaveContext) {
|
||||
public getTabRefWithSaveContext(ctx: Partial<HoppRESTSaveContext>) {
|
||||
for (const tab of this.tabMap.values()) {
|
||||
// For `team-collection` request id can be considered unique
|
||||
if (ctx?.originLocation === "team-collection") {
|
||||
@@ -50,8 +51,40 @@ export class RESTTabService extends TabService<HoppRESTDocument> {
|
||||
) {
|
||||
return this.getTabRef(tab.id)
|
||||
}
|
||||
} else if (isEqual(ctx, tab.document.saveContext)) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +95,20 @@ export class RESTTabService extends TabService<HoppRESTDocument> {
|
||||
let count = 0
|
||||
|
||||
for (const tab of this.tabMap.values()) {
|
||||
if (tab.document.isDirty) count++
|
||||
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++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return count
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { refWithControl } from "@vueuse/core"
|
||||
import { Service } from "dioc"
|
||||
import * as E from "fp-ts/Either"
|
||||
import { v4 as uuidV4 } from "uuid"
|
||||
import {
|
||||
ComputedRef,
|
||||
@@ -10,16 +11,23 @@ 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"])
|
||||
|
||||
@@ -36,9 +44,9 @@ export abstract class TabService<Doc>
|
||||
},
|
||||
})
|
||||
|
||||
public currentActiveTab = computed(
|
||||
() => this.tabMap.get(this.currentTabID.value)!
|
||||
) // Guaranteed to not be undefined
|
||||
public currentActiveTab = computed(() => {
|
||||
return this.tabMap.get(this.currentTabID.value)!
|
||||
}) // Guaranteed to not be undefined
|
||||
|
||||
protected watchCurrentTabID() {
|
||||
watch(
|
||||
@@ -82,15 +90,63 @@ export abstract class TabService<Doc>
|
||||
this.currentTabID.value = tabID
|
||||
}
|
||||
|
||||
public loadTabsFromPersistedState(data: PersistableTabState<Doc>): void {
|
||||
public async loadTabsFromPersistedState(
|
||||
data: PersistableTabState<Doc>
|
||||
): Promise<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: doc.doc,
|
||||
document: resolvedTabDoc,
|
||||
})
|
||||
|
||||
this.tabOrdering.value.push(doc.tabID)
|
||||
@@ -99,10 +155,11 @@ 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)!))
|
||||
computed(() =>
|
||||
this.tabOrdering.value.map((x) => this.tabMap.get(x) as HoppTab<Doc>)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -180,13 +237,55 @@ 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: tab.document,
|
||||
doc: this.getPersistedDocument(tab.document),
|
||||
}
|
||||
}),
|
||||
}))
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
"rxjs": "7.8.1",
|
||||
"stream-browserify": "3.0.0",
|
||||
"util": "0.12.5",
|
||||
"vue": "3.3.9",
|
||||
"vue": "3.4.27",
|
||||
"workbox-window": "7.0.0",
|
||||
"zod": "3.22.4"
|
||||
},
|
||||
|
||||
831
pnpm-lock.yaml
generated
831
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user