refactor: monorepo+pnpm (removed husky)

This commit is contained in:
Andrew Bastin
2021-09-10 00:28:28 +05:30
parent 917550ff4d
commit b28f82a881
445 changed files with 81301 additions and 63752 deletions

View File

@@ -0,0 +1,74 @@
import { Subject, BehaviorSubject } from "rxjs"
import { map } from "rxjs/operators"
import assign from "lodash/assign"
import clone from "lodash/clone"
type dispatcherFunc<StoreType> = (
currentVal: StoreType,
payload: any
) => Partial<StoreType>
/**
* Defines a dispatcher.
*
* This function exists to provide better typing for dispatch function.
* As you can see, its pretty much an identity function.
*/
export const defineDispatchers = <StoreType, T>(
// eslint-disable-next-line no-unused-vars
dispatchers: { [_ in keyof T]: dispatcherFunc<StoreType> }
) => dispatchers
type Dispatch<
StoreType,
DispatchersType extends Record<string, dispatcherFunc<StoreType>>
> = {
dispatcher: keyof DispatchersType
payload: any
}
export default class DispatchingStore<
StoreType,
DispatchersType extends Record<string, dispatcherFunc<StoreType>>
> {
#state$: BehaviorSubject<StoreType>
#dispatchers: DispatchersType
#dispatches$: Subject<Dispatch<StoreType, DispatchersType>> = new Subject()
constructor(initialValue: StoreType, dispatchers: DispatchersType) {
this.#state$ = new BehaviorSubject(initialValue)
this.#dispatchers = dispatchers
this.#dispatches$
.pipe(
map(({ dispatcher, payload }) =>
this.#dispatchers[dispatcher](this.value, payload)
)
)
.subscribe((val) => {
const data = clone(this.value)
assign(data, val)
this.#state$.next(data)
})
}
get subject$() {
return this.#state$
}
get value() {
return this.subject$.value
}
get dispatches$() {
return this.#dispatches$
}
dispatch({ dispatcher, payload }: Dispatch<StoreType, DispatchersType>) {
if (!this.#dispatchers[dispatcher])
throw new Error(`Undefined dispatch type '${dispatcher}'`)
this.#dispatches$.next({ dispatcher, payload })
}
}

View File

@@ -0,0 +1,249 @@
import { distinctUntilChanged, pluck } from "rxjs/operators"
import DispatchingStore, { defineDispatchers } from "./DispatchingStore"
import { useStream } from "~/helpers/utils/composables"
import {
GQLHeader,
HoppGQLRequest,
makeGQLRequest,
} from "~/helpers/types/HoppGQLRequest"
type GQLSession = {
request: HoppGQLRequest
schema: string
response: string
}
export const defaultGQLSession: GQLSession = {
request: makeGQLRequest({
name: "",
url: "https://rickandmortyapi.com/graphql",
headers: [],
variables: `{ "id": "1" }`,
query: `query GetCharacter($id: ID!) {
character(id: $id) {
id
name
}
}`,
}),
schema: "",
response: "",
}
const dispatchers = defineDispatchers({
setSession(_: GQLSession, { session }: { session: GQLSession }) {
return session
},
setName(curr: GQLSession, { newName }: { newName: string }) {
return {
request: {
...curr.request,
name: newName,
},
}
},
setURL(curr: GQLSession, { newURL }: { newURL: string }) {
return {
request: {
...curr.request,
url: newURL,
},
}
},
setHeaders(curr: GQLSession, { headers }: { headers: GQLHeader[] }) {
return {
request: {
...curr.request,
headers,
},
}
},
addHeader(curr: GQLSession, { header }: { header: GQLHeader }) {
return {
request: {
...curr.request,
headers: [...curr.request.headers, header],
},
}
},
removeHeader(curr: GQLSession, { headerIndex }: { headerIndex: number }) {
return {
request: {
...curr.request,
headers: curr.request.headers.filter((_x, i) => i !== headerIndex),
},
}
},
updateHeader(
curr: GQLSession,
{
headerIndex,
updatedHeader,
}: { headerIndex: number; updatedHeader: GQLHeader }
) {
return {
request: {
...curr.request,
headers: curr.request.headers.map((x, i) =>
i === headerIndex ? updatedHeader : x
),
},
}
},
setQuery(curr: GQLSession, { newQuery }: { newQuery: string }) {
return {
request: {
...curr.request,
query: newQuery,
},
}
},
setVariables(curr: GQLSession, { newVariables }: { newVariables: string }) {
return {
request: {
...curr.request,
variables: newVariables,
},
}
},
setResponse(_: GQLSession, { newResponse }: { newResponse: string }) {
return {
response: newResponse,
}
},
})
export const gqlSessionStore = new DispatchingStore(
defaultGQLSession,
dispatchers
)
export function setGQLURL(newURL: string) {
gqlSessionStore.dispatch({
dispatcher: "setURL",
payload: {
newURL,
},
})
}
export function setGQLHeaders(headers: GQLHeader[]) {
gqlSessionStore.dispatch({
dispatcher: "setHeaders",
payload: {
headers,
},
})
}
export function addGQLHeader(header: GQLHeader) {
gqlSessionStore.dispatch({
dispatcher: "addHeader",
payload: {
header,
},
})
}
export function updateGQLHeader(headerIndex: number, updatedHeader: GQLHeader) {
gqlSessionStore.dispatch({
dispatcher: "updateHeader",
payload: {
headerIndex,
updatedHeader,
},
})
}
export function removeGQLHeader(headerIndex: number) {
gqlSessionStore.dispatch({
dispatcher: "removeHeader",
payload: {
headerIndex,
},
})
}
export function clearGQLHeaders() {
gqlSessionStore.dispatch({
dispatcher: "setHeaders",
payload: {
headers: [],
},
})
}
export function setGQLQuery(newQuery: string) {
gqlSessionStore.dispatch({
dispatcher: "setQuery",
payload: {
newQuery,
},
})
}
export function setGQLVariables(newVariables: string) {
gqlSessionStore.dispatch({
dispatcher: "setVariables",
payload: {
newVariables,
},
})
}
export function setGQLResponse(newResponse: string) {
gqlSessionStore.dispatch({
dispatcher: "setResponse",
payload: {
newResponse,
},
})
}
export function getGQLSession() {
return gqlSessionStore.value
}
export function setGQLSession(session: GQLSession) {
gqlSessionStore.dispatch({
dispatcher: "setSession",
payload: {
session,
},
})
}
export function useGQLRequestName() {
return useStream(gqlName$, "", (newName) => {
gqlSessionStore.dispatch({
dispatcher: "setName",
payload: { newName },
})
})
}
export const gqlName$ = gqlSessionStore.subject$.pipe(
pluck("request", "name"),
distinctUntilChanged()
)
export const gqlURL$ = gqlSessionStore.subject$.pipe(
pluck("request", "url"),
distinctUntilChanged()
)
export const gqlQuery$ = gqlSessionStore.subject$.pipe(
pluck("request", "query"),
distinctUntilChanged()
)
export const gqlVariables$ = gqlSessionStore.subject$.pipe(
pluck("request", "variables"),
distinctUntilChanged()
)
export const gqlHeaders$ = gqlSessionStore.subject$.pipe(
pluck("request", "headers"),
distinctUntilChanged()
)
export const gqlResponse$ = gqlSessionStore.subject$.pipe(
pluck("response"),
distinctUntilChanged()
)

View File

@@ -0,0 +1,749 @@
import { pluck, distinctUntilChanged, map, filter } from "rxjs/operators"
import { Ref } from "@nuxtjs/composition-api"
import DispatchingStore, { defineDispatchers } from "./DispatchingStore"
import {
FormDataKeyValue,
HoppRESTHeader,
HoppRESTParam,
HoppRESTReqBody,
HoppRESTRequest,
RESTReqSchemaVersion,
} from "~/helpers/types/HoppRESTRequest"
import { HoppRESTResponse } from "~/helpers/types/HoppRESTResponse"
import { useStream } from "~/helpers/utils/composables"
import { HoppTestResult } from "~/helpers/types/HoppTestResult"
import { HoppRESTAuth } from "~/helpers/types/HoppRESTAuth"
import { ValidContentTypes } from "~/helpers/utils/contenttypes"
import { HoppRequestSaveContext } from "~/helpers/types/HoppRequestSaveContext"
type RESTSession = {
request: HoppRESTRequest
response: HoppRESTResponse | null
testResults: HoppTestResult | null
saveContext: HoppRequestSaveContext | null
}
export const defaultRESTRequest: HoppRESTRequest = {
v: RESTReqSchemaVersion,
endpoint: "https://echo.hoppscotch.io",
name: "Untitled request",
params: [{ key: "", value: "", active: true }],
headers: [{ key: "", value: "", active: true }],
method: "GET",
auth: {
authType: "none",
authActive: true,
},
preRequestScript: "",
testScript: "",
body: {
contentType: null,
body: null,
},
}
const defaultRESTSession: RESTSession = {
request: defaultRESTRequest,
response: null,
testResults: null,
saveContext: null,
}
const dispatchers = defineDispatchers({
setRequest(_: RESTSession, { req }: { req: HoppRESTRequest }) {
return {
request: req,
}
},
setRequestName(curr: RESTSession, { newName }: { newName: string }) {
return {
request: {
...curr.request,
name: newName,
},
}
},
setEndpoint(curr: RESTSession, { newEndpoint }: { newEndpoint: string }) {
return {
request: {
...curr.request,
endpoint: newEndpoint,
},
}
},
setParams(curr: RESTSession, { entries }: { entries: HoppRESTParam[] }) {
return {
request: {
...curr.request,
params: entries,
},
}
},
addParam(curr: RESTSession, { newParam }: { newParam: HoppRESTParam }) {
return {
request: {
...curr.request,
params: [...curr.request.params, newParam],
},
}
},
updateParam(
curr: RESTSession,
{ index, updatedParam }: { index: number; updatedParam: HoppRESTParam }
) {
const newParams = curr.request.params.map((param, i) => {
if (i === index) return updatedParam
else return param
})
return {
request: {
...curr.request,
params: newParams,
},
}
},
deleteParam(curr: RESTSession, { index }: { index: number }) {
const newParams = curr.request.params.filter((_x, i) => i !== index)
return {
request: {
...curr.request,
params: newParams,
},
}
},
deleteAllParams(curr: RESTSession) {
return {
request: {
...curr.request,
params: [],
},
}
},
updateMethod(curr: RESTSession, { newMethod }: { newMethod: string }) {
return {
request: {
...curr.request,
method: newMethod,
},
}
},
setHeaders(curr: RESTSession, { entries }: { entries: HoppRESTHeader[] }) {
return {
request: {
...curr.request,
headers: entries,
},
}
},
addHeader(curr: RESTSession, { entry }: { entry: HoppRESTHeader }) {
return {
request: {
...curr.request,
headers: [...curr.request.headers, entry],
},
}
},
updateHeader(
curr: RESTSession,
{ index, updatedEntry }: { index: number; updatedEntry: HoppRESTHeader }
) {
return {
request: {
...curr.request,
headers: curr.request.headers.map((header, i) => {
if (i === index) return updatedEntry
else return header
}),
},
}
},
deleteHeader(curr: RESTSession, { index }: { index: number }) {
return {
request: {
...curr.request,
headers: curr.request.headers.filter((_, i) => i !== index),
},
}
},
deleteAllHeaders(curr: RESTSession) {
return {
request: {
...curr.request,
headers: [],
},
}
},
setAuth(curr: RESTSession, { newAuth }: { newAuth: HoppRESTAuth }) {
return {
request: {
...curr.request,
auth: newAuth,
},
}
},
setPreRequestScript(curr: RESTSession, { newScript }: { newScript: string }) {
return {
request: {
...curr.request,
preRequestScript: newScript,
},
}
},
setTestScript(curr: RESTSession, { newScript }: { newScript: string }) {
return {
request: {
...curr.request,
testScript: newScript,
},
}
},
setContentType(
curr: RESTSession,
{ newContentType }: { newContentType: ValidContentTypes | null }
) {
// TODO: persist body evenafter switching content typees
if (curr.request.body.contentType !== "multipart/form-data") {
if (newContentType === "multipart/form-data") {
// Going from non-formdata to form-data, discard contents and set empty array as body
return {
request: {
...curr.request,
body: <HoppRESTReqBody>{
contentType: "multipart/form-data",
body: [],
},
},
}
} else {
// non-formdata to non-formdata, keep body and set content type
return {
request: {
...curr.request,
body: <HoppRESTReqBody>{
contentType: newContentType,
body:
newContentType === null
? null
: (curr.request.body as any)?.body ?? "",
},
},
}
}
} else if (newContentType !== "multipart/form-data") {
// Going from formdata to non-formdata, discard contents and set empty string
return {
request: {
...curr.request,
body: <HoppRESTReqBody>{
contentType: newContentType,
body: "",
},
},
}
} else {
// form-data to form-data ? just set the content type ¯\_(ツ)_/¯
return {
request: {
...curr.request,
body: <HoppRESTReqBody>{
contentType: newContentType,
body: curr.request.body.body,
},
},
}
}
},
addFormDataEntry(curr: RESTSession, { entry }: { entry: FormDataKeyValue }) {
// Only perform update if the current content-type is formdata
if (curr.request.body.contentType !== "multipart/form-data") return {}
return {
request: {
...curr.request,
body: <HoppRESTReqBody>{
contentType: "multipart/form-data",
body: [...curr.request.body.body, entry],
},
},
}
},
deleteFormDataEntry(curr: RESTSession, { index }: { index: number }) {
// Only perform update if the current content-type is formdata
if (curr.request.body.contentType !== "multipart/form-data") return {}
return {
request: {
...curr.request,
body: <HoppRESTReqBody>{
contentType: "multipart/form-data",
body: curr.request.body.body.filter((_, i) => i !== index),
},
},
}
},
updateFormDataEntry(
curr: RESTSession,
{ index, entry }: { index: number; entry: FormDataKeyValue }
) {
// Only perform update if the current content-type is formdata
if (curr.request.body.contentType !== "multipart/form-data") return {}
return {
request: {
...curr.request,
body: <HoppRESTReqBody>{
contentType: "multipart/form-data",
body: curr.request.body.body.map((x, i) => (i !== index ? x : entry)),
},
},
}
},
deleteAllFormDataEntries(curr: RESTSession) {
// Only perform update if the current content-type is formdata
if (curr.request.body.contentType !== "multipart/form-data") return {}
return {
request: {
...curr.request,
body: <HoppRESTReqBody>{
contentType: "multipart/form-data",
body: [],
},
},
}
},
setRequestBody(curr: RESTSession, { newBody }: { newBody: HoppRESTReqBody }) {
return {
request: {
...curr.request,
body: newBody,
},
}
},
updateResponse(
_curr: RESTSession,
{ updatedRes }: { updatedRes: HoppRESTResponse | null }
) {
return {
response: updatedRes,
}
},
clearResponse(_curr: RESTSession) {
return {
response: null,
}
},
setTestResults(
_curr: RESTSession,
{ newResults }: { newResults: HoppTestResult | null }
) {
return {
testResults: newResults,
}
},
setSaveContext(
_,
{ newContext }: { newContext: HoppRequestSaveContext | null }
) {
return {
saveContext: newContext,
}
},
})
const restSessionStore = new DispatchingStore(defaultRESTSession, dispatchers)
export function getRESTRequest() {
return restSessionStore.subject$.value.request
}
export function setRESTRequest(
req: HoppRESTRequest,
saveContext?: HoppRequestSaveContext | null
) {
restSessionStore.dispatch({
dispatcher: "setRequest",
payload: {
req,
},
})
if (saveContext) setRESTSaveContext(saveContext)
}
export function setRESTSaveContext(saveContext: HoppRequestSaveContext | null) {
restSessionStore.dispatch({
dispatcher: "setSaveContext",
payload: {
newContext: saveContext,
},
})
}
export function getRESTSaveContext() {
return restSessionStore.value.saveContext
}
export function resetRESTRequest() {
setRESTRequest(defaultRESTRequest)
}
export function setRESTEndpoint(newEndpoint: string) {
restSessionStore.dispatch({
dispatcher: "setEndpoint",
payload: {
newEndpoint,
},
})
}
export function setRESTRequestName(newName: string) {
restSessionStore.dispatch({
dispatcher: "setRequestName",
payload: {
newName,
},
})
}
export function setRESTParams(entries: HoppRESTParam[]) {
restSessionStore.dispatch({
dispatcher: "setParams",
payload: {
entries,
},
})
}
export function addRESTParam(newParam: HoppRESTParam) {
restSessionStore.dispatch({
dispatcher: "addParam",
payload: {
newParam,
},
})
}
export function updateRESTParam(index: number, updatedParam: HoppRESTParam) {
restSessionStore.dispatch({
dispatcher: "updateParam",
payload: {
updatedParam,
index,
},
})
}
export function deleteRESTParam(index: number) {
restSessionStore.dispatch({
dispatcher: "deleteParam",
payload: {
index,
},
})
}
export function deleteAllRESTParams() {
restSessionStore.dispatch({
dispatcher: "deleteAllParams",
payload: {},
})
}
export function updateRESTMethod(newMethod: string) {
restSessionStore.dispatch({
dispatcher: "updateMethod",
payload: {
newMethod,
},
})
}
export function setRESTHeaders(entries: HoppRESTHeader[]) {
restSessionStore.dispatch({
dispatcher: "setHeaders",
payload: {
entries,
},
})
}
export function addRESTHeader(entry: HoppRESTHeader) {
restSessionStore.dispatch({
dispatcher: "addHeader",
payload: {
entry,
},
})
}
export function updateRESTHeader(index: number, updatedEntry: HoppRESTHeader) {
restSessionStore.dispatch({
dispatcher: "updateHeader",
payload: {
index,
updatedEntry,
},
})
}
export function deleteRESTHeader(index: number) {
restSessionStore.dispatch({
dispatcher: "deleteHeader",
payload: {
index,
},
})
}
export function deleteAllRESTHeaders() {
restSessionStore.dispatch({
dispatcher: "deleteAllHeaders",
payload: {},
})
}
export function setRESTAuth(newAuth: HoppRESTAuth) {
restSessionStore.dispatch({
dispatcher: "setAuth",
payload: {
newAuth,
},
})
}
export function setRESTPreRequestScript(newScript: string) {
restSessionStore.dispatch({
dispatcher: "setPreRequestScript",
payload: {
newScript,
},
})
}
export function setRESTTestScript(newScript: string) {
restSessionStore.dispatch({
dispatcher: "setTestScript",
payload: {
newScript,
},
})
}
export function setRESTReqBody(newBody: HoppRESTReqBody | null) {
restSessionStore.dispatch({
dispatcher: "setRequestBody",
payload: {
newBody,
},
})
}
export function updateRESTResponse(updatedRes: HoppRESTResponse | null) {
restSessionStore.dispatch({
dispatcher: "updateResponse",
payload: {
updatedRes,
},
})
}
export function clearRESTResponse() {
restSessionStore.dispatch({
dispatcher: "clearResponse",
payload: {},
})
}
export function setRESTTestResults(newResults: HoppTestResult | null) {
restSessionStore.dispatch({
dispatcher: "setTestResults",
payload: {
newResults,
},
})
}
export function addFormDataEntry(entry: FormDataKeyValue) {
restSessionStore.dispatch({
dispatcher: "addFormDataEntry",
payload: {
entry,
},
})
}
export function deleteFormDataEntry(index: number) {
restSessionStore.dispatch({
dispatcher: "deleteFormDataEntry",
payload: {
index,
},
})
}
export function updateFormDataEntry(index: number, entry: FormDataKeyValue) {
restSessionStore.dispatch({
dispatcher: "updateFormDataEntry",
payload: {
index,
entry,
},
})
}
export function setRESTContentType(newContentType: ValidContentTypes | null) {
restSessionStore.dispatch({
dispatcher: "setContentType",
payload: {
newContentType,
},
})
}
export function deleteAllFormDataEntries() {
restSessionStore.dispatch({
dispatcher: "deleteAllFormDataEntries",
payload: {},
})
}
export const restSaveContext$ = restSessionStore.subject$.pipe(
pluck("saveContext"),
distinctUntilChanged()
)
export const restRequest$ = restSessionStore.subject$.pipe(
pluck("request"),
distinctUntilChanged()
)
export const restRequestName$ = restRequest$.pipe(
pluck("name"),
distinctUntilChanged()
)
export const restEndpoint$ = restSessionStore.subject$.pipe(
pluck("request", "endpoint"),
distinctUntilChanged()
)
export const restParams$ = restSessionStore.subject$.pipe(
pluck("request", "params"),
distinctUntilChanged()
)
export const restActiveParamsCount$ = restParams$.pipe(
map(
(params) =>
params.filter((x) => x.active && (x.key !== "" || x.value !== "")).length
)
)
export const restMethod$ = restSessionStore.subject$.pipe(
pluck("request", "method"),
distinctUntilChanged()
)
export const restHeaders$ = restSessionStore.subject$.pipe(
pluck("request", "headers"),
distinctUntilChanged()
)
export const restActiveHeadersCount$ = restHeaders$.pipe(
map(
(params) =>
params.filter((x) => x.active && (x.key !== "" || x.value !== "")).length
)
)
export const restAuth$ = restRequest$.pipe(pluck("auth"))
export const restPreRequestScript$ = restSessionStore.subject$.pipe(
pluck("request", "preRequestScript"),
distinctUntilChanged()
)
export const restContentType$ = restRequest$.pipe(
pluck("body", "contentType"),
distinctUntilChanged()
)
export const restTestScript$ = restSessionStore.subject$.pipe(
pluck("request", "testScript"),
distinctUntilChanged()
)
export const restReqBody$ = restSessionStore.subject$.pipe(
pluck("request", "body"),
distinctUntilChanged()
)
export const restResponse$ = restSessionStore.subject$.pipe(
pluck("response"),
distinctUntilChanged()
)
export const completedRESTResponse$ = restResponse$.pipe(
filter(
(res) =>
res !== null && res.type !== "loading" && res.type !== "network_fail"
)
)
export const restTestResults$ = restSessionStore.subject$.pipe(
pluck("testResults"),
distinctUntilChanged()
)
/**
* A Vue 3 composable function that gives access to a ref
* which is updated to the preRequestScript value in the store.
* The ref value is kept in sync with the store and all writes
* to the ref are dispatched to the store as `setPreRequestScript`
* dispatches.
*/
export function usePreRequestScript(): Ref<string> {
return useStream(
restPreRequestScript$,
restSessionStore.value.request.preRequestScript,
(value) => {
setRESTPreRequestScript(value)
}
)
}
/**
* A Vue 3 composable function that gives access to a ref
* which is updated to the testScript value in the store.
* The ref value is kept in sync with the store and all writes
* to the ref are dispatched to the store as `setTestScript`
* dispatches.
*/
export function useTestScript(): Ref<string> {
return useStream(
restTestScript$,
restSessionStore.value.request.testScript,
(value) => {
setRESTTestScript(value)
}
)
}
export function useRESTRequestBody(): Ref<HoppRESTReqBody> {
return useStream(
restReqBody$,
restSessionStore.value.request.body,
setRESTReqBody
)
}
export function useRESTRequestName(): Ref<string> {
return useStream(
restRequestName$,
restSessionStore.value.request.name,
setRESTRequestName
)
}

View File

@@ -0,0 +1,192 @@
import { BehaviorSubject, Subject } from "rxjs"
import isEqual from "lodash/isEqual"
import DispatchingStore from "~/newstore/DispatchingStore"
describe("DispatchingStore", () => {
test("'subject$' property properly returns an BehaviorSubject", () => {
const store = new DispatchingStore({}, {})
expect(store.subject$ instanceof BehaviorSubject).toEqual(true)
})
test("'value' property properly returns the current state value", () => {
const store = new DispatchingStore({}, {})
expect(store.value).toEqual({})
})
test("'dispatches$' property properly returns a Subject", () => {
const store = new DispatchingStore({}, {})
expect(store.dispatches$ instanceof Subject).toEqual(true)
})
test("dispatch with invalid dispatcher are thrown", () => {
const store = new DispatchingStore({}, {})
expect(() => {
store.dispatch({
dispatcher: "non-existent",
payload: {},
})
}).toThrow()
})
test("valid dispatcher calls run without throwing", () => {
const store = new DispatchingStore(
{},
{
testDispatcher(_currentValue, _payload) {
// Nothing here
},
}
)
expect(() => {
store.dispatch({
dispatcher: "testDispatcher",
payload: {},
})
}).not.toThrow()
})
test("only correct dispatcher method is ran", () => {
const dispatchFn = jest.fn().mockReturnValue({})
const dontCallDispatchFn = jest.fn().mockReturnValue({})
const store = new DispatchingStore(
{},
{
testDispatcher: dispatchFn,
dontCallDispatcher: dontCallDispatchFn,
}
)
store.dispatch({
dispatcher: "testDispatcher",
payload: {},
})
expect(dispatchFn).toHaveBeenCalledTimes(1)
expect(dontCallDispatchFn).not.toHaveBeenCalled()
})
test("passes current value and the payload to the dispatcher", () => {
const testInitValue = { name: "bob" }
const testPayload = { name: "alice" }
const testDispatchFn = jest.fn().mockReturnValue({})
const store = new DispatchingStore(testInitValue, {
testDispatcher: testDispatchFn,
})
store.dispatch({
dispatcher: "testDispatcher",
payload: testPayload,
})
expect(testDispatchFn).toHaveBeenCalledWith(testInitValue, testPayload)
})
test("dispatcher returns are used to update the store correctly", () => {
const testInitValue = { name: "bob" }
const testDispatchReturnVal = { name: "alice" }
const testDispatchFn = jest.fn().mockReturnValue(testDispatchReturnVal)
const store = new DispatchingStore(testInitValue, {
testDispatcher: testDispatchFn,
})
store.dispatch({
dispatcher: "testDispatcher",
payload: {}, // Payload doesn't matter because the function is mocked
})
expect(store.value).toEqual(testDispatchReturnVal)
})
test("dispatching patches in new values if not existing on the store", () => {
const testInitValue = { name: "bob" }
const testDispatchReturnVal = { age: 25 }
const testDispatchFn = jest.fn().mockReturnValue(testDispatchReturnVal)
const store = new DispatchingStore(testInitValue, {
testDispatcher: testDispatchFn,
})
store.dispatch({
dispatcher: "testDispatcher",
payload: {},
})
expect(store.value).toEqual({
name: "bob",
age: 25,
})
})
test("emits the current store value to the new subscribers", (done) => {
const testInitValue = { name: "bob" }
const testDispatchFn = jest.fn().mockReturnValue({})
const store = new DispatchingStore(testInitValue, {
testDispatcher: testDispatchFn,
})
store.subject$.subscribe((value) => {
if (value === testInitValue) {
done()
}
})
})
test("emits the dispatched store value to the subscribers", (done) => {
const testInitValue = { name: "bob" }
const testDispatchReturnVal = { age: 25 }
const testDispatchFn = jest.fn().mockReturnValue(testDispatchReturnVal)
const store = new DispatchingStore(testInitValue, {
testDispatcher: testDispatchFn,
})
store.subject$.subscribe((value) => {
if (isEqual(value, { name: "bob", age: 25 })) {
done()
}
})
store.dispatch({
dispatcher: "testDispatcher",
payload: {},
})
})
test("dispatching emits the new dispatch requests to the subscribers", () => {
const testInitValue = { name: "bob" }
const testPayload = { age: 25 }
const testDispatchFn = jest.fn().mockReturnValue({})
const store = new DispatchingStore(testInitValue, {
testDispatcher: testDispatchFn,
})
store.dispatches$.subscribe((value) => {
if (
isEqual(value, { dispatcher: "testDispatcher", payload: testPayload })
) {
done()
}
})
store.dispatch({
dispatcher: "testDispatcher",
payload: {},
})
})
})

View File

@@ -0,0 +1,943 @@
import { pluck } from "rxjs/operators"
import DispatchingStore, { defineDispatchers } from "./DispatchingStore"
import { getRESTSaveContext, setRESTSaveContext } from "./RESTSession"
import {
HoppRESTRequest,
translateToNewRequest,
} from "~/helpers/types/HoppRESTRequest"
import {
HoppGQLRequest,
translateToGQLRequest,
} from "~/helpers/types/HoppGQLRequest"
export interface Collection<T extends HoppRESTRequest | HoppGQLRequest> {
v: number
name: string
folders: Collection<T>[]
requests: T[]
id?: string // For Firestore ID
}
const defaultRESTCollectionState = {
state: [
makeCollection<HoppRESTRequest>({
name: "My Collection",
folders: [],
requests: [],
}),
],
}
const defaultGraphqlCollectionState = {
state: [
makeCollection<HoppGQLRequest>({
name: "My GraphQL Collection",
folders: [],
requests: [],
}),
],
}
export function makeCollection<T extends HoppRESTRequest | HoppGQLRequest>(
x: Omit<Collection<T>, "v">
): Collection<T> {
return {
v: 1,
...x,
}
}
export function translateToNewRESTCollection(
x: any
): Collection<HoppRESTRequest> {
if (x.v && x.v === 1) return x
// Legacy
const name = x.name ?? "Untitled"
const folders = (x.folders ?? []).map(translateToNewRESTCollection)
const requests = (x.requests ?? []).map(translateToNewRequest)
const obj = makeCollection<HoppRESTRequest>({
name,
folders,
requests,
})
if (x.id) obj.id = x.id
return obj
}
export function translateToNewGQLCollection(
x: any
): Collection<HoppGQLRequest> {
if (x.v && x.v === 1) return x
// Legacy
const name = x.name ?? "Untitled"
const folders = (x.folders ?? []).map(translateToNewGQLCollection)
const requests = (x.requests ?? []).map(translateToGQLRequest)
const obj = makeCollection<HoppGQLRequest>({
name,
folders,
requests,
})
if (x.id) obj.id = x.id
return obj
}
type RESTCollectionStoreType = typeof defaultRESTCollectionState
type GraphqlCollectionStoreType = typeof defaultGraphqlCollectionState
function navigateToFolderWithIndexPath(
collections: Collection<HoppRESTRequest | HoppGQLRequest>[],
indexPaths: number[]
) {
if (indexPaths.length === 0) return null
let target = collections[indexPaths.shift() as number]
while (indexPaths.length > 0)
target = target.folders[indexPaths.shift() as number]
return target !== undefined ? target : null
}
const restCollectionDispatchers = defineDispatchers({
setCollections(
_: RESTCollectionStoreType,
{ entries }: { entries: Collection<HoppRESTRequest>[] }
) {
return {
state: entries,
}
},
appendCollections(
{ state }: RESTCollectionStoreType,
{ entries }: { entries: Collection<HoppRESTRequest>[] }
) {
return {
state: [...state, ...entries],
}
},
addCollection(
{ state }: RESTCollectionStoreType,
{ collection }: { collection: Collection<any> }
) {
return {
state: [...state, collection],
}
},
removeCollection(
{ state }: RESTCollectionStoreType,
{ collectionIndex }: { collectionIndex: number }
) {
return {
state: (state as any).filter(
(_: any, i: number) => i !== collectionIndex
),
}
},
editCollection(
{ state }: RESTCollectionStoreType,
{
collectionIndex,
collection,
}: { collectionIndex: number; collection: Collection<any> }
) {
return {
state: state.map((col, index) =>
index === collectionIndex ? collection : col
),
}
},
addFolder(
{ state }: RESTCollectionStoreType,
{ name, path }: { name: string; path: string }
) {
const newFolder: Collection<HoppRESTRequest> = makeCollection({
name,
folders: [],
requests: [],
})
const newState = state
const indexPaths = path.split("/").map((x) => parseInt(x))
const target = navigateToFolderWithIndexPath(newState, indexPaths)
if (target === null) {
console.log(`Could not parse path '${path}'. Ignoring add folder request`)
return {}
}
target.folders.push(newFolder)
return {
state: newState,
}
},
editFolder(
{ state }: RESTCollectionStoreType,
{ path, folder }: { path: string; folder: string }
) {
const newState = state
const indexPaths = path.split("/").map((x) => parseInt(x))
const target = navigateToFolderWithIndexPath(newState, indexPaths)
if (target === null) {
console.log(
`Could not parse path '${path}'. Ignoring edit folder request`
)
return {}
}
Object.assign(target, folder)
return {
state: newState,
}
},
removeFolder({ state }: RESTCollectionStoreType, { path }: { path: string }) {
const newState = state
const indexPaths = path.split("/").map((x) => parseInt(x))
if (indexPaths.length === 0) {
console.log(
"Given path too short. If this is a collection, use removeCollection dispatcher instead. Skipping request."
)
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
const folderIndex = indexPaths.pop() as number
const containingFolder = navigateToFolderWithIndexPath(newState, indexPaths)
if (containingFolder === null) {
console.log(
`Could not resolve path '${path}'. Skipping removeFolder dispatch.`
)
return {}
}
containingFolder.folders.splice(folderIndex, 1)
return {
state: newState,
}
},
editRequest(
{ state }: RESTCollectionStoreType,
{
path,
requestIndex,
requestNew,
}: { path: string; requestIndex: number; requestNew: any }
) {
const newState = state
const indexPaths = path.split("/").map((x) => parseInt(x))
const targetLocation = navigateToFolderWithIndexPath(newState, indexPaths)
if (targetLocation === null) {
console.log(
`Could not resolve path '${path}'. Ignoring editRequest dispatch.`
)
return {}
}
targetLocation.requests = targetLocation.requests.map((req, index) =>
index !== requestIndex ? req : requestNew
)
return {
state: newState,
}
},
saveRequestAs(
{ state }: RESTCollectionStoreType,
{ path, request }: { path: string; request: any }
) {
const newState = state
const indexPaths = path.split("/").map((x) => parseInt(x))
const targetLocation = navigateToFolderWithIndexPath(newState, indexPaths)
if (targetLocation === null) {
console.log(
`Could not resolve path '${path}'. Ignoring saveRequestAs dispatch.`
)
return {}
}
targetLocation.requests.push(request)
return {
state: newState,
}
},
removeRequest(
{ state }: RESTCollectionStoreType,
{ path, requestIndex }: { path: string; requestIndex: number }
) {
const newState = state
const indexPaths = path.split("/").map((x) => parseInt(x))
const targetLocation = navigateToFolderWithIndexPath(newState, indexPaths)
if (targetLocation === null) {
console.log(
`Could not resolve path '${path}'. Ignoring removeRequest dispatch.`
)
return {}
}
targetLocation.requests.splice(requestIndex, 1)
// If the save context is set and is set to the same source, we invalidate it
const saveCtx = getRESTSaveContext()
if (
saveCtx?.originLocation === "user-collection" &&
saveCtx.folderPath === path &&
saveCtx.requestIndex === requestIndex
) {
setRESTSaveContext(null)
}
return {
state: newState,
}
},
moveRequest(
{ state }: RESTCollectionStoreType,
{
path,
requestIndex,
destinationPath,
}: { path: string; requestIndex: number; destinationPath: string }
) {
const newState = state
const indexPaths = path.split("/").map((x) => parseInt(x))
const targetLocation = navigateToFolderWithIndexPath(newState, indexPaths)
if (targetLocation === null) {
console.log(
`Could not resolve source path '${path}'. Skipping moveRequest dispatch.`
)
return {}
}
const req = targetLocation.requests[requestIndex]
const destIndexPaths = destinationPath.split("/").map((x) => parseInt(x))
const destLocation = navigateToFolderWithIndexPath(newState, destIndexPaths)
if (destLocation === null) {
console.log(
`Could not resolve destination path '${destinationPath}'. Skipping moveRequest dispatch.`
)
return {}
}
destLocation.requests.push(req)
targetLocation.requests.splice(requestIndex, 1)
return {
state: newState,
}
},
})
const gqlCollectionDispatchers = defineDispatchers({
setCollections(
_: GraphqlCollectionStoreType,
{ entries }: { entries: Collection<any>[] }
) {
return {
state: entries,
}
},
appendCollections(
{ state }: GraphqlCollectionStoreType,
{ entries }: { entries: Collection<any>[] }
) {
return {
state: [...state, ...entries],
}
},
addCollection(
{ state }: GraphqlCollectionStoreType,
{ collection }: { collection: Collection<any> }
) {
return {
state: [...state, collection],
}
},
removeCollection(
{ state }: GraphqlCollectionStoreType,
{ collectionIndex }: { collectionIndex: number }
) {
return {
state: (state as any).filter(
(_: any, i: number) => i !== collectionIndex
),
}
},
editCollection(
{ state }: GraphqlCollectionStoreType,
{
collectionIndex,
collection,
}: { collectionIndex: number; collection: Collection<any> }
) {
return {
state: state.map((col, index) =>
index === collectionIndex ? collection : col
),
}
},
addFolder(
{ state }: GraphqlCollectionStoreType,
{ name, path }: { name: string; path: string }
) {
const newFolder: Collection<HoppGQLRequest> = makeCollection({
name,
folders: [],
requests: [],
})
const newState = state
const indexPaths = path.split("/").map((x) => parseInt(x))
const target = navigateToFolderWithIndexPath(newState, indexPaths)
if (target === null) {
console.log(`Could not parse path '${path}'. Ignoring add folder request`)
return {}
}
target.folders.push(newFolder)
return {
state: newState,
}
},
editFolder(
{ state }: GraphqlCollectionStoreType,
{ path, folder }: { path: string; folder: string }
) {
const newState = state
const indexPaths = path.split("/").map((x) => parseInt(x))
const target = navigateToFolderWithIndexPath(newState, indexPaths)
if (target === null) {
console.log(
`Could not parse path '${path}'. Ignoring edit folder request`
)
return {}
}
Object.assign(target, folder)
return {
state: newState,
}
},
removeFolder(
{ state }: GraphqlCollectionStoreType,
{ path }: { path: string }
) {
const newState = state
const indexPaths = path.split("/").map((x) => parseInt(x))
if (indexPaths.length === 0) {
console.log(
"Given path too short. If this is a collection, use removeCollection dispatcher instead. Skipping request."
)
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
const folderIndex = indexPaths.pop() as number
const containingFolder = navigateToFolderWithIndexPath(newState, indexPaths)
if (containingFolder === null) {
console.log(
`Could not resolve path '${path}'. Skipping removeFolder dispatch.`
)
return {}
}
containingFolder.folders.splice(folderIndex, 1)
return {
state: newState,
}
},
editRequest(
{ state }: GraphqlCollectionStoreType,
{
path,
requestIndex,
requestNew,
}: { path: string; requestIndex: number; requestNew: any }
) {
const newState = state
const indexPaths = path.split("/").map((x) => parseInt(x))
const targetLocation = navigateToFolderWithIndexPath(newState, indexPaths)
if (targetLocation === null) {
console.log(
`Could not resolve path '${path}'. Ignoring editRequest dispatch.`
)
return {}
}
targetLocation.requests = targetLocation.requests.map((req, index) =>
index !== requestIndex ? req : requestNew
)
return {
state: newState,
}
},
saveRequestAs(
{ state }: GraphqlCollectionStoreType,
{ path, request }: { path: string; request: any }
) {
const newState = state
const indexPaths = path.split("/").map((x) => parseInt(x))
const targetLocation = navigateToFolderWithIndexPath(newState, indexPaths)
if (targetLocation === null) {
console.log(
`Could not resolve path '${path}'. Ignoring saveRequestAs dispatch.`
)
return {}
}
targetLocation.requests.push(request)
return {
state: newState,
}
},
removeRequest(
{ state }: GraphqlCollectionStoreType,
{ path, requestIndex }: { path: string; requestIndex: number }
) {
const newState = state
const indexPaths = path.split("/").map((x) => parseInt(x))
const targetLocation = navigateToFolderWithIndexPath(newState, indexPaths)
if (targetLocation === null) {
console.log(
`Could not resolve path '${path}'. Ignoring removeRequest dispatch.`
)
return {}
}
targetLocation.requests.splice(requestIndex, 1)
// If the save context is set and is set to the same source, we invalidate it
const saveCtx = getRESTSaveContext()
if (
saveCtx?.originLocation === "user-collection" &&
saveCtx.folderPath === path &&
saveCtx.requestIndex === requestIndex
) {
setRESTSaveContext(null)
}
return {
state: newState,
}
},
moveRequest(
{ state }: GraphqlCollectionStoreType,
{
path,
requestIndex,
destinationPath,
}: { path: string; requestIndex: number; destinationPath: string }
) {
const newState = state
const indexPaths = path.split("/").map((x) => parseInt(x))
const targetLocation = navigateToFolderWithIndexPath(newState, indexPaths)
if (targetLocation === null) {
console.log(
`Could not resolve source path '${path}'. Skipping moveRequest dispatch.`
)
return {}
}
const req = targetLocation.requests[requestIndex]
const destIndexPaths = destinationPath.split("/").map((x) => parseInt(x))
const destLocation = navigateToFolderWithIndexPath(newState, destIndexPaths)
if (destLocation === null) {
console.log(
`Could not resolve destination path '${destinationPath}'. Skipping moveRequest dispatch.`
)
return {}
}
destLocation.requests.push(req)
targetLocation.requests.splice(requestIndex, 1)
return {
state: newState,
}
},
})
export const restCollectionStore = new DispatchingStore(
defaultRESTCollectionState,
restCollectionDispatchers
)
export const graphqlCollectionStore = new DispatchingStore(
defaultGraphqlCollectionState,
gqlCollectionDispatchers
)
export function setRESTCollections(entries: Collection<HoppRESTRequest>[]) {
restCollectionStore.dispatch({
dispatcher: "setCollections",
payload: {
entries,
},
})
}
export const restCollections$ = restCollectionStore.subject$.pipe(
pluck("state")
)
export const graphqlCollections$ = graphqlCollectionStore.subject$.pipe(
pluck("state")
)
export function appendRESTCollections(entries: Collection<HoppRESTRequest>[]) {
restCollectionStore.dispatch({
dispatcher: "appendCollections",
payload: {
entries,
},
})
}
export function addRESTCollection(collection: Collection<HoppRESTRequest>) {
restCollectionStore.dispatch({
dispatcher: "addCollection",
payload: {
collection,
},
})
}
export function removeRESTCollection(collectionIndex: number) {
restCollectionStore.dispatch({
dispatcher: "removeCollection",
payload: {
collectionIndex,
},
})
}
export function editRESTCollection(
collectionIndex: number,
collection: Collection<HoppRESTRequest>
) {
restCollectionStore.dispatch({
dispatcher: "editCollection",
payload: {
collectionIndex,
collection,
},
})
}
export function addRESTFolder(name: string, path: string) {
restCollectionStore.dispatch({
dispatcher: "addFolder",
payload: {
name,
path,
},
})
}
export function editRESTFolder(
path: string,
folder: Collection<HoppRESTRequest>
) {
restCollectionStore.dispatch({
dispatcher: "editFolder",
payload: {
path,
folder,
},
})
}
export function removeRESTFolder(path: string) {
restCollectionStore.dispatch({
dispatcher: "removeFolder",
payload: {
path,
},
})
}
export function editRESTRequest(
path: string,
requestIndex: number,
requestNew: HoppRESTRequest
) {
restCollectionStore.dispatch({
dispatcher: "editRequest",
payload: {
path,
requestIndex,
requestNew,
},
})
}
export function saveRESTRequestAs(path: string, request: HoppRESTRequest) {
// For calculating the insertion request index
const targetLocation = navigateToFolderWithIndexPath(
restCollectionStore.value.state,
path.split("/").map((x) => parseInt(x))
)
const insertionIndex = targetLocation!.requests.length
restCollectionStore.dispatch({
dispatcher: "saveRequestAs",
payload: {
path,
request,
},
})
return insertionIndex
}
export function removeRESTRequest(path: string, requestIndex: number) {
restCollectionStore.dispatch({
dispatcher: "removeRequest",
payload: {
path,
requestIndex,
},
})
}
export function moveRESTRequest(
path: string,
requestIndex: number,
destinationPath: string
) {
restCollectionStore.dispatch({
dispatcher: "moveRequest",
payload: {
path,
requestIndex,
destinationPath,
},
})
}
export function setGraphqlCollections(entries: Collection<HoppGQLRequest>[]) {
graphqlCollectionStore.dispatch({
dispatcher: "setCollections",
payload: {
entries,
},
})
}
export function appendGraphqlCollections(
entries: Collection<HoppGQLRequest>[]
) {
graphqlCollectionStore.dispatch({
dispatcher: "appendCollections",
payload: {
entries,
},
})
}
export function addGraphqlCollection(collection: Collection<HoppGQLRequest>) {
graphqlCollectionStore.dispatch({
dispatcher: "addCollection",
payload: {
collection,
},
})
}
export function removeGraphqlCollection(collectionIndex: number) {
graphqlCollectionStore.dispatch({
dispatcher: "removeCollection",
payload: {
collectionIndex,
},
})
}
export function editGraphqlCollection(
collectionIndex: number,
collection: Collection<HoppGQLRequest>
) {
graphqlCollectionStore.dispatch({
dispatcher: "editCollection",
payload: {
collectionIndex,
collection,
},
})
}
export function addGraphqlFolder(name: string, path: string) {
graphqlCollectionStore.dispatch({
dispatcher: "addFolder",
payload: {
name,
path,
},
})
}
export function editGraphqlFolder(
path: string,
folder: Collection<HoppGQLRequest>
) {
graphqlCollectionStore.dispatch({
dispatcher: "editFolder",
payload: {
path,
folder,
},
})
}
export function removeGraphqlFolder(path: string) {
graphqlCollectionStore.dispatch({
dispatcher: "removeFolder",
payload: {
path,
},
})
}
export function editGraphqlRequest(
path: string,
requestIndex: number,
requestNew: HoppGQLRequest
) {
graphqlCollectionStore.dispatch({
dispatcher: "editRequest",
payload: {
path,
requestIndex,
requestNew,
},
})
}
export function saveGraphqlRequestAs(path: string, request: HoppGQLRequest) {
graphqlCollectionStore.dispatch({
dispatcher: "saveRequestAs",
payload: {
path,
request,
},
})
}
export function removeGraphqlRequest(path: string, requestIndex: number) {
graphqlCollectionStore.dispatch({
dispatcher: "removeRequest",
payload: {
path,
requestIndex,
},
})
}
export function moveGraphqlRequest(
path: string,
requestIndex: number,
destinationPath: string
) {
graphqlCollectionStore.dispatch({
dispatcher: "moveRequest",
payload: {
path,
requestIndex,
destinationPath,
},
})
}

View File

@@ -0,0 +1,489 @@
import isEqual from "lodash/isEqual"
import { combineLatest } from "rxjs"
import { distinctUntilChanged, map, pluck } from "rxjs/operators"
import DispatchingStore, {
defineDispatchers,
} from "~/newstore/DispatchingStore"
export type Environment = {
name: string
variables: {
key: string
value: string
}[]
}
const defaultEnvironmentsState = {
environments: [
{
name: "My Environment Variables",
variables: [],
},
] as Environment[],
globals: [] as Environment["variables"],
// Current environment index specifies the index
// -1 means no environments are selected
currentEnvironmentIndex: -1,
}
type EnvironmentStore = typeof defaultEnvironmentsState
const dispatchers = defineDispatchers({
setCurrentEnviromentIndex(
{ environments }: EnvironmentStore,
{ newIndex }: { newIndex: number }
) {
if (newIndex >= environments.length || newIndex <= -2) {
console.log(
`Ignoring possibly invalid current environment index assignment (value: ${newIndex})`
)
return {}
}
return {
currentEnvironmentIndex: newIndex,
}
},
appendEnvironments(
{ environments }: EnvironmentStore,
{ envs }: { envs: Environment[] }
) {
return {
environments: [...environments, ...envs],
}
},
replaceEnvironments(
_: EnvironmentStore,
{ environments }: { environments: Environment[] }
) {
return {
environments,
}
},
createEnvironment(
{ environments }: EnvironmentStore,
{ name }: { name: string }
) {
return {
environments: [
...environments,
{
name,
variables: [],
},
],
}
},
deleteEnvironment(
{ environments, currentEnvironmentIndex }: EnvironmentStore,
{ envIndex }: { envIndex: number }
) {
return {
environments: environments.filter((_, index) => index !== envIndex),
currentEnvironmentIndex:
envIndex === currentEnvironmentIndex ? -1 : currentEnvironmentIndex,
}
},
renameEnvironment(
{ environments }: EnvironmentStore,
{ envIndex, newName }: { envIndex: number; newName: string }
) {
return {
environments: environments.map((env, index) =>
index === envIndex
? {
...env,
name: newName,
}
: env
),
}
},
updateEnvironment(
{ environments }: EnvironmentStore,
{ envIndex, updatedEnv }: { envIndex: number; updatedEnv: Environment }
) {
return {
environments: environments.map((env, index) =>
index === envIndex ? updatedEnv : env
),
}
},
addEnvironmentVariable(
{ environments }: EnvironmentStore,
{ envIndex, key, value }: { envIndex: number; key: string; value: string }
) {
return {
environments: environments.map((env, index) =>
index === envIndex
? {
...env,
variables: [...env.variables, { key, value }],
}
: env
),
}
},
removeEnvironmentVariable(
{ environments }: EnvironmentStore,
{ envIndex, variableIndex }: { envIndex: number; variableIndex: number }
) {
return {
environments: environments.map((env, index) =>
index === envIndex
? {
...env,
variables: env.variables.filter(
(_, vIndex) => vIndex !== variableIndex
),
}
: env
),
}
},
setEnvironmentVariables(
{ environments }: EnvironmentStore,
{
envIndex,
vars,
}: { envIndex: number; vars: { key: string; value: string }[] }
) {
return {
environments: environments.map((env, index) =>
index === envIndex
? {
...env,
variables: vars,
}
: env
),
}
},
updateEnvironmentVariable(
{ environments }: EnvironmentStore,
{
envIndex,
variableIndex,
updatedKey,
updatedValue,
}: {
envIndex: number
variableIndex: number
updatedKey: string
updatedValue: string
}
) {
return {
environments: environments.map((env, index) =>
index === envIndex
? {
...env,
variables: env.variables.map((v, vIndex) =>
vIndex === variableIndex
? { key: updatedKey, value: updatedValue }
: v
),
}
: env
),
}
},
setGlobalVariables(_, { entries }: { entries: Environment["variables"] }) {
return {
globals: entries,
}
},
clearGlobalVariables() {
return {
globals: [],
}
},
addGlobalVariable(
{ globals },
{ entry }: { entry: Environment["variables"][number] }
) {
return {
globals: [...globals, entry],
}
},
removeGlobalVariable({ globals }, { envIndex }: { envIndex: number }) {
return {
globals: globals.filter((_, i) => i !== envIndex),
}
},
updateGlobalVariable(
{ globals },
{
envIndex,
updatedEntry,
}: { envIndex: number; updatedEntry: Environment["variables"][number] }
) {
return {
globals: globals.map((x, i) => (i !== envIndex ? x : updatedEntry)),
}
},
})
export const environmentsStore = new DispatchingStore(
defaultEnvironmentsState,
dispatchers
)
export const environments$ = environmentsStore.subject$.pipe(
pluck("environments"),
distinctUntilChanged()
)
export const globalEnv$ = environmentsStore.subject$.pipe(
pluck("globals"),
distinctUntilChanged()
)
export const selectedEnvIndex$ = environmentsStore.subject$.pipe(
pluck("currentEnvironmentIndex"),
distinctUntilChanged()
)
export const currentEnvironment$ = combineLatest([
environments$,
selectedEnvIndex$,
]).pipe(
map(([envs, selectedIndex]) => {
if (selectedIndex === -1) {
const env: Environment = {
name: "No environment",
variables: [],
}
return env
} else {
return envs[selectedIndex]
}
})
)
/**
* Stream returning all the environment variables accessible in
* the current state (Global + The Selected Environment).
* NOTE: The source environment attribute will be "Global" for Global Env as source.
*/
export const aggregateEnvs$ = combineLatest([
currentEnvironment$,
globalEnv$,
]).pipe(
map(([selectedEnv, globalVars]) => {
const results: { key: string; value: string; sourceEnv: string }[] = []
selectedEnv.variables.forEach(({ key, value }) =>
results.push({ key, value, sourceEnv: selectedEnv.name })
)
globalVars.forEach(({ key, value }) =>
results.push({ key, value, sourceEnv: "Global" })
)
return results
}),
distinctUntilChanged(isEqual)
)
export function getCurrentEnvironment(): Environment {
if (environmentsStore.value.currentEnvironmentIndex === -1) {
return {
name: "No environment",
variables: [],
}
}
return environmentsStore.value.environments[
environmentsStore.value.currentEnvironmentIndex
]
}
export function setCurrentEnvironment(newEnvIndex: number) {
environmentsStore.dispatch({
dispatcher: "setCurrentEnviromentIndex",
payload: {
newIndex: newEnvIndex,
},
})
}
export function getLegacyGlobalEnvironment(): Environment | null {
const envs = environmentsStore.value.environments
const el = envs.find(
(env) => env.name === "globals" || env.name === "Globals"
)
return el ?? null
}
export function getGlobalVariables(): Environment["variables"] {
return environmentsStore.value.globals
}
export function addGlobalEnvVariable(entry: Environment["variables"][number]) {
environmentsStore.dispatch({
dispatcher: "addGlobalVariable",
payload: {
entry,
},
})
}
export function setGlobalEnvVariables(entries: Environment["variables"]) {
environmentsStore.dispatch({
dispatcher: "setGlobalVariables",
payload: {
entries,
},
})
}
export function clearGlobalEnvVariables() {
environmentsStore.dispatch({
dispatcher: "clearGlobalVariables",
payload: {},
})
}
export function removeGlobalEnvVariable(envIndex: number) {
environmentsStore.dispatch({
dispatcher: "removeGlobalVariable",
payload: {
envIndex,
},
})
}
export function updateGlobalEnvVariable(
envIndex: number,
updatedEntry: Environment["variables"][number]
) {
environmentsStore.dispatch({
dispatcher: "updateGlobalVariable",
payload: {
envIndex,
updatedEntry,
},
})
}
export function replaceEnvironments(newEnvironments: any[]) {
environmentsStore.dispatch({
dispatcher: "replaceEnvironments",
payload: {
environments: newEnvironments,
},
})
}
export function appendEnvironments(envs: Environment[]) {
environmentsStore.dispatch({
dispatcher: "appendEnvironments",
payload: {
envs,
},
})
}
export function createEnvironment(envName: string) {
environmentsStore.dispatch({
dispatcher: "createEnvironment",
payload: {
name: envName,
},
})
}
export function deleteEnvironment(envIndex: number) {
environmentsStore.dispatch({
dispatcher: "deleteEnvironment",
payload: {
envIndex,
},
})
}
export function renameEnvironment(envIndex: number, newName: string) {
environmentsStore.dispatch({
dispatcher: "renameEnvironment",
payload: {
envIndex,
newName,
},
})
}
export function updateEnvironment(envIndex: number, updatedEnv: Environment) {
environmentsStore.dispatch({
dispatcher: "updateEnvironment",
payload: {
envIndex,
updatedEnv,
},
})
}
export function setEnvironmentVariables(
envIndex: number,
vars: { key: string; value: string }[]
) {
environmentsStore.dispatch({
dispatcher: "setEnvironmentVariables",
payload: {
envIndex,
vars,
},
})
}
export function addEnvironmentVariable(
envIndex: number,
{ key, value }: { key: string; value: string }
) {
environmentsStore.dispatch({
dispatcher: "addEnvironmentVariable",
payload: {
envIndex,
key,
value,
},
})
}
export function removeEnvironmentVariable(
envIndex: number,
variableIndex: number
) {
environmentsStore.dispatch({
dispatcher: "removeEnvironmentVariable",
payload: {
envIndex,
variableIndex,
},
})
}
export function updateEnvironmentVariable(
envIndex: number,
variableIndex: number,
{ key, value }: { key: string; value: string }
) {
environmentsStore.dispatch({
dispatcher: "updateEnvironmentVariable",
payload: {
envIndex,
variableIndex,
updatedKey: key,
updatedValue: value,
},
})
}
export function getEnviroment(index: number) {
return environmentsStore.value.environments[index]
}

View File

@@ -0,0 +1,305 @@
import eq from "lodash/eq"
import { pluck } from "rxjs/operators"
import DispatchingStore, { defineDispatchers } from "./DispatchingStore"
import { completedRESTResponse$ } from "./RESTSession"
import {
HoppRESTRequest,
translateToNewRequest,
} from "~/helpers/types/HoppRESTRequest"
import {
HoppGQLRequest,
translateToGQLRequest,
} from "~/helpers/types/HoppGQLRequest"
export type RESTHistoryEntry = {
v: number
request: HoppRESTRequest
responseMeta: {
duration: number | null
statusCode: number | null
}
star: boolean
id?: string // For when Firebase Firestore is set
}
export type GQLHistoryEntry = {
v: number
request: HoppGQLRequest
response: string
star: boolean
id?: string // For when Firestore ID is set
}
export function makeRESTHistoryEntry(
x: Omit<RESTHistoryEntry, "v">
): RESTHistoryEntry {
return {
v: 1,
...x,
}
}
export function makeGQLHistoryEntry(
x: Omit<GQLHistoryEntry, "v">
): GQLHistoryEntry {
return {
v: 1,
...x,
}
}
export function translateToNewRESTHistory(x: any): RESTHistoryEntry {
if (x.v === 1) return x
// Legacy
const request = translateToNewRequest(x)
const star = x.star ?? false
const duration = x.duration ?? null
const statusCode = x.status ?? null
const obj: RESTHistoryEntry = makeRESTHistoryEntry({
request,
star,
responseMeta: {
duration,
statusCode,
},
})
if (x.id) obj.id = x.id
return obj
}
export function translateToNewGQLHistory(x: any): GQLHistoryEntry {
if (x.v === 1) return x
// Legacy
const request = translateToGQLRequest(x)
const star = x.star ?? false
const response = x.response ?? ""
const obj: GQLHistoryEntry = makeGQLHistoryEntry({
request,
star,
response,
})
if (x.id) obj.id = x.id
return obj
}
export const defaultRESTHistoryState = {
state: [] as RESTHistoryEntry[],
}
export const defaultGraphqlHistoryState = {
state: [] as GQLHistoryEntry[],
}
export const HISTORY_LIMIT = 50
type RESTHistoryType = typeof defaultRESTHistoryState
type GraphqlHistoryType = typeof defaultGraphqlHistoryState
const RESTHistoryDispatchers = defineDispatchers({
setEntries(_: RESTHistoryType, { entries }: { entries: RESTHistoryEntry[] }) {
return {
state: entries,
}
},
addEntry(
currentVal: RESTHistoryType,
{ entry }: { entry: RESTHistoryEntry }
) {
return {
state: [entry, ...currentVal.state].slice(0, HISTORY_LIMIT),
}
},
deleteEntry(
currentVal: RESTHistoryType,
{ entry }: { entry: RESTHistoryEntry }
) {
return {
state: currentVal.state.filter((e) => !eq(e, entry)),
}
},
clearHistory() {
return {
state: [],
}
},
toggleStar(
currentVal: RESTHistoryType,
{ entry }: { entry: RESTHistoryEntry }
) {
return {
state: currentVal.state.map((e) => {
if (eq(e, entry) && e.star !== undefined) {
return {
...e,
star: !e.star,
}
}
return e
}),
}
},
})
const GQLHistoryDispatchers = defineDispatchers({
setEntries(
_: GraphqlHistoryType,
{ entries }: { entries: GQLHistoryEntry[] }
) {
return {
state: entries,
}
},
addEntry(
currentVal: GraphqlHistoryType,
{ entry }: { entry: GQLHistoryEntry }
) {
return {
state: [entry, ...currentVal.state].slice(0, HISTORY_LIMIT),
}
},
deleteEntry(
currentVal: GraphqlHistoryType,
{ entry }: { entry: GQLHistoryEntry }
) {
return {
state: currentVal.state.filter((e) => !eq(e, entry)),
}
},
clearHistory() {
return {
state: [],
}
},
toggleStar(
currentVal: GraphqlHistoryType,
{ entry }: { entry: GQLHistoryEntry }
) {
return {
state: currentVal.state.map((e) => {
if (eq(e, entry) && e.star !== undefined) {
return {
...e,
star: !e.star,
}
}
return e
}),
}
},
})
export const restHistoryStore = new DispatchingStore(
defaultRESTHistoryState,
RESTHistoryDispatchers
)
export const graphqlHistoryStore = new DispatchingStore(
defaultGraphqlHistoryState,
GQLHistoryDispatchers
)
export const restHistory$ = restHistoryStore.subject$.pipe(pluck("state"))
export const graphqlHistory$ = graphqlHistoryStore.subject$.pipe(pluck("state"))
export function setRESTHistoryEntries(entries: RESTHistoryEntry[]) {
restHistoryStore.dispatch({
dispatcher: "setEntries",
payload: { entries },
})
}
export function addRESTHistoryEntry(entry: RESTHistoryEntry) {
restHistoryStore.dispatch({
dispatcher: "addEntry",
payload: { entry },
})
}
export function deleteRESTHistoryEntry(entry: RESTHistoryEntry) {
restHistoryStore.dispatch({
dispatcher: "deleteEntry",
payload: { entry },
})
}
export function clearRESTHistory() {
restHistoryStore.dispatch({
dispatcher: "clearHistory",
payload: {},
})
}
export function toggleRESTHistoryEntryStar(entry: RESTHistoryEntry) {
restHistoryStore.dispatch({
dispatcher: "toggleStar",
payload: { entry },
})
}
export function setGraphqlHistoryEntries(entries: GQLHistoryEntry[]) {
graphqlHistoryStore.dispatch({
dispatcher: "setEntries",
payload: { entries },
})
}
export function addGraphqlHistoryEntry(entry: GQLHistoryEntry) {
graphqlHistoryStore.dispatch({
dispatcher: "addEntry",
payload: { entry },
})
}
export function deleteGraphqlHistoryEntry(entry: GQLHistoryEntry) {
graphqlHistoryStore.dispatch({
dispatcher: "deleteEntry",
payload: { entry },
})
}
export function clearGraphqlHistory() {
graphqlHistoryStore.dispatch({
dispatcher: "clearHistory",
payload: {},
})
}
export function toggleGraphqlHistoryEntryStar(entry: GQLHistoryEntry) {
graphqlHistoryStore.dispatch({
dispatcher: "toggleStar",
payload: { entry },
})
}
// Listen to completed responses to add to history
completedRESTResponse$.subscribe((res) => {
if (res !== null) {
if (res.type === "loading" || res.type === "network_fail") return
addRESTHistoryEntry(
makeRESTHistoryEntry({
request: res.req,
responseMeta: {
duration: res.meta.responseDuration,
statusCode: res.statusCode,
},
star: false,
})
)
}
})

View File

@@ -0,0 +1,252 @@
/* eslint-disable no-restricted-globals, no-restricted-syntax */
import clone from "lodash/clone"
import assign from "lodash/assign"
import isEmpty from "lodash/isEmpty"
import {
settingsStore,
bulkApplySettings,
defaultSettings,
applySetting,
HoppAccentColor,
HoppBgColor,
} from "./settings"
import {
restHistoryStore,
graphqlHistoryStore,
setRESTHistoryEntries,
setGraphqlHistoryEntries,
translateToNewRESTHistory,
translateToNewGQLHistory,
} from "./history"
import {
restCollectionStore,
graphqlCollectionStore,
setGraphqlCollections,
setRESTCollections,
translateToNewRESTCollection,
translateToNewGQLCollection,
} from "./collections"
import {
replaceEnvironments,
environments$,
Environment,
addGlobalEnvVariable,
setGlobalEnvVariables,
globalEnv$,
} from "./environments"
import { restRequest$, setRESTRequest } from "./RESTSession"
import { translateToNewRequest } from "~/helpers/types/HoppRESTRequest"
function checkAndMigrateOldSettings() {
const vuexData = JSON.parse(window.localStorage.getItem("vuex") || "{}")
if (isEmpty(vuexData)) return
const { postwoman } = vuexData
if (!isEmpty(postwoman?.settings)) {
const settingsData = assign(clone(defaultSettings), postwoman.settings)
window.localStorage.setItem("settings", JSON.stringify(settingsData))
delete postwoman.settings
window.localStorage.setItem("vuex", JSON.stringify(vuexData))
}
if (postwoman?.collections) {
window.localStorage.setItem(
"collections",
JSON.stringify(postwoman.collections)
)
delete postwoman.collections
window.localStorage.setItem("vuex", JSON.stringify(vuexData))
}
if (postwoman?.collectionsGraphql) {
window.localStorage.setItem(
"collectionsGraphql",
JSON.stringify(postwoman.collectionsGraphql)
)
delete postwoman.collectionsGraphql
window.localStorage.setItem("vuex", JSON.stringify(vuexData))
}
if (postwoman?.environments) {
window.localStorage.setItem(
"environments",
JSON.stringify(postwoman.environments)
)
delete postwoman.environments
window.localStorage.setItem("vuex", JSON.stringify(vuexData))
}
if (window.localStorage.getItem("THEME_COLOR")) {
const themeColor = window.localStorage.getItem("THEME_COLOR")
applySetting("THEME_COLOR", themeColor as HoppAccentColor)
window.localStorage.removeItem("THEME_COLOR")
}
if (window.localStorage.getItem("nuxt-color-mode")) {
const color = window.localStorage.getItem("nuxt-color-mode") as HoppBgColor
applySetting("BG_COLOR", color)
window.localStorage.removeItem("nuxt-color-mode")
}
}
function setupSettingsPersistence() {
const settingsData = JSON.parse(
window.localStorage.getItem("settings") || "{}"
)
if (settingsData) {
bulkApplySettings(settingsData)
}
settingsStore.subject$.subscribe((settings) => {
window.localStorage.setItem("settings", JSON.stringify(settings))
})
}
function setupHistoryPersistence() {
const restHistoryData = JSON.parse(
window.localStorage.getItem("history") || "[]"
).map(translateToNewRESTHistory)
const graphqlHistoryData = JSON.parse(
window.localStorage.getItem("graphqlHistory") || "[]"
).map(translateToNewGQLHistory)
setRESTHistoryEntries(restHistoryData)
setGraphqlHistoryEntries(graphqlHistoryData)
restHistoryStore.subject$.subscribe(({ state }) => {
window.localStorage.setItem("history", JSON.stringify(state))
})
graphqlHistoryStore.subject$.subscribe(({ state }) => {
window.localStorage.setItem("graphqlHistory", JSON.stringify(state))
})
}
function setupCollectionsPersistence() {
const restCollectionData = JSON.parse(
window.localStorage.getItem("collections") || "[]"
).map(translateToNewRESTCollection)
const graphqlCollectionData = JSON.parse(
window.localStorage.getItem("collectionsGraphql") || "[]"
).map(translateToNewGQLCollection)
setRESTCollections(restCollectionData)
setGraphqlCollections(graphqlCollectionData)
restCollectionStore.subject$.subscribe(({ state }) => {
window.localStorage.setItem("collections", JSON.stringify(state))
})
graphqlCollectionStore.subject$.subscribe(({ state }) => {
window.localStorage.setItem("collectionsGraphql", JSON.stringify(state))
})
}
function setupEnvironmentsPersistence() {
const environmentsData: Environment[] = JSON.parse(
window.localStorage.getItem("environments") || "[]"
)
// Check if a global env is defined and if so move that to globals
const globalIndex = environmentsData.findIndex(
(x) => x.name.toLowerCase() === "globals"
)
if (globalIndex !== -1) {
const globalEnv = environmentsData[globalIndex]
globalEnv.variables.forEach((variable) => addGlobalEnvVariable(variable))
// Remove global from environments
environmentsData.splice(globalIndex, 1)
// Just sync the changes manually
window.localStorage.setItem(
"environments",
JSON.stringify(environmentsData)
)
}
replaceEnvironments(environmentsData)
environments$.subscribe((envs) => {
window.localStorage.setItem("environments", JSON.stringify(envs))
})
}
function setupGlobalEnvsPersistence() {
const globals: Environment["variables"] = JSON.parse(
window.localStorage.getItem("globalEnv") || "[]"
)
setGlobalEnvVariables(globals)
globalEnv$.subscribe((vars) => {
window.localStorage.setItem("globalEnv", JSON.stringify(vars))
})
}
function setupRequestPersistence() {
const localRequest = JSON.parse(
window.localStorage.getItem("restRequest") || "null"
)
if (localRequest) {
const parsedLocal = translateToNewRequest(localRequest)
setRESTRequest(parsedLocal)
}
restRequest$.subscribe((req) => {
window.localStorage.setItem("restRequest", JSON.stringify(req))
})
}
export function setupLocalPersistence() {
checkAndMigrateOldSettings()
setupSettingsPersistence()
setupRequestPersistence()
setupHistoryPersistence()
setupCollectionsPersistence()
setupGlobalEnvsPersistence()
setupEnvironmentsPersistence()
}
/**
* Gets a value in LocalStorage.
*
* NOTE: Use LocalStorage to only store non-reactive simple data
* For more complex data, use stores and connect it to localpersistence
*/
export function getLocalConfig(name: string) {
return window.localStorage.getItem(name)
}
/**
* Sets a value in LocalStorage.
*
* NOTE: Use LocalStorage to only store non-reactive simple data
* For more complex data, use stores and connect it to localpersistence
*/
export function setLocalConfig(key: string, value: string) {
window.localStorage.setItem(key, value)
}
/**
* Clear config value in LocalStorage.
* @param key Key to be cleared
*/
export function removeLocalConfig(key: string) {
window.localStorage.removeItem(key)
}

View File

@@ -0,0 +1,184 @@
import { pluck, distinctUntilChanged } from "rxjs/operators"
import has from "lodash/has"
import { Observable } from "rxjs"
import { Ref } from "@nuxtjs/composition-api"
import DispatchingStore, { defineDispatchers } from "./DispatchingStore"
import type { KeysMatching } from "~/types/ts-utils"
import { useStream } from "~/helpers/utils/composables"
export const HoppBgColors = ["system", "light", "dark", "black"] as const
export type HoppBgColor = typeof HoppBgColors[number]
export const HoppAccentColors = [
"green",
"teal",
"blue",
"indigo",
"purple",
"yellow",
"orange",
"red",
"pink",
] as const
export type HoppAccentColor = typeof HoppAccentColors[number]
export const HoppFontSizes = ["small", "medium", "large"] as const
export type HoppFontSize = typeof HoppFontSizes[number]
export type SettingsType = {
syncCollections: boolean
syncHistory: boolean
syncEnvironments: boolean
PROXY_ENABLED: boolean
PROXY_URL: string
PROXY_KEY: string
EXTENSIONS_ENABLED: boolean
EXPERIMENTAL_URL_BAR_ENABLED: boolean
URL_EXCLUDES: {
auth: boolean
httpUser: boolean
httpPassword: boolean
bearerToken: boolean
oauth2Token: boolean
}
THEME_COLOR: HoppAccentColor
BG_COLOR: HoppBgColor
TELEMETRY_ENABLED: boolean
LEFT_SIDEBAR: boolean
RIGHT_SIDEBAR: boolean
ZEN_MODE: boolean
FONT_SIZE: HoppFontSize
}
export const defaultSettings: SettingsType = {
syncCollections: true,
syncHistory: true,
syncEnvironments: true,
PROXY_ENABLED: false,
PROXY_URL: "https://proxy.hoppscotch.io/",
PROXY_KEY: "",
EXTENSIONS_ENABLED: true,
EXPERIMENTAL_URL_BAR_ENABLED: true,
URL_EXCLUDES: {
auth: true,
httpUser: true,
httpPassword: true,
bearerToken: true,
oauth2Token: true,
},
THEME_COLOR: "blue",
BG_COLOR: "system",
TELEMETRY_ENABLED: true,
LEFT_SIDEBAR: true,
RIGHT_SIDEBAR: true,
ZEN_MODE: false,
FONT_SIZE: "small",
}
const validKeys = Object.keys(defaultSettings)
const dispatchers = defineDispatchers({
bulkApplySettings(
_currentState: SettingsType,
payload: Partial<SettingsType>
) {
return payload
},
toggleSetting(
currentState: SettingsType,
{ settingKey }: { settingKey: KeysMatching<SettingsType, boolean> }
) {
if (!has(currentState, settingKey)) {
console.log(
`Toggling of a non-existent setting key '${settingKey}' ignored.`
)
return {}
}
const result: Partial<SettingsType> = {}
result[settingKey] = !currentState[settingKey]
return result
},
applySetting<K extends keyof SettingsType>(
_currentState: SettingsType,
{ settingKey, value }: { settingKey: K; value: SettingsType[K] }
) {
if (!validKeys.includes(settingKey)) {
console.log(
`Ignoring non-existent setting key '${settingKey}' assignment`
)
return {}
}
const result: Partial<SettingsType> = {}
result[settingKey] = value
return result
},
})
export const settingsStore = new DispatchingStore(defaultSettings, dispatchers)
/**
* An observable value to make avail all the state information at once
*/
export const settings$ = settingsStore.subject$.asObservable()
export function getSettingSubject<K extends keyof SettingsType>(
settingKey: K
): Observable<SettingsType[K]> {
return settingsStore.subject$.pipe(pluck(settingKey), distinctUntilChanged())
}
export function bulkApplySettings(settingsObj: Partial<SettingsType>) {
settingsStore.dispatch({
dispatcher: "bulkApplySettings",
payload: settingsObj,
})
}
export function toggleSetting(settingKey: KeysMatching<SettingsType, boolean>) {
settingsStore.dispatch({
dispatcher: "toggleSetting",
payload: {
settingKey,
},
})
}
export function applySetting<K extends keyof SettingsType>(
settingKey: K,
value: SettingsType[K]
) {
settingsStore.dispatch({
dispatcher: "applySetting",
payload: {
settingKey,
value,
},
})
}
export function useSetting<K extends keyof SettingsType>(
settingKey: K
): Ref<SettingsType[K]> {
return useStream(
settingsStore.subject$.pipe(pluck(settingKey), distinctUntilChanged()),
settingsStore.value[settingKey],
(value: SettingsType[K]) => {
settingsStore.dispatch({
dispatcher: "applySetting",
payload: {
settingKey,
value,
},
})
}
)
}