Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

#RI-31 - Logical databases #223

Merged
merged 23 commits into from
Dec 17, 2024
Merged
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
6577d79
#RI-31 - Logical databases
zalenskiSofteq Dec 10, 2024
ca02933
#RI-31 - Logical databases
zalenskiSofteq Dec 10, 2024
3f481a8
#RI-31 - Logical databases
zalenskiSofteq Dec 10, 2024
2c91e15
#RI-31 - Logical databases
zalenskiSofteq Dec 10, 2024
d39a583
#RI-31 - Logical databases
zalenskiSofteq Dec 10, 2024
7c69e8d
#RI-31 - Logical databases
zalenskiSofteq Dec 10, 2024
cc61be2
#RI-31 - Logical databases
zalenskiSofteq Dec 10, 2024
2da5cdc
#RI-31 - fix pr comments
zalenskiSofteq Dec 10, 2024
1fddabf
#RI-31 - fix pr comments
zalenskiSofteq Dec 10, 2024
73460be
#RI-31 - fix pr comments
zalenskiSofteq Dec 10, 2024
5b2b3f3
#RIVS-299 - Add the changes from the main project
zalenskiSofteq Dec 12, 2024
9aea8d8
#RIVS-313 - Keys scanned value overlapped for small screen width
zalenskiSofteq Dec 12, 2024
ce5df8e
#RIVS-304 - Auto refresh
zalenskiSofteq Dec 13, 2024
de8c107
#RIVS-304 - Auto refresh
zalenskiSofteq Dec 13, 2024
14c5489
#RIVS-305 - Do not collapse the list of keys when going to another ex…
zalenskiSofteq Dec 13, 2024
3d6b7a2
#RIVS-305 - Turn on keytar encryption for new users
zalenskiSofteq Dec 13, 2024
9f8dd25
default
zalenskiSofteq Dec 13, 2024
f3e73a9
add html ui tests report
zalenskiSofteq Dec 13, 2024
1a1f928
fix builds
zalenskiSofteq Dec 13, 2024
b86d53f
Merge pull request #225 from RedisInsight/feature/bugfix
vlad-dargel Dec 16, 2024
31c9472
Merge pull request #226 from RedisInsight/feature/stories
vlad-dargel Dec 17, 2024
9330dea
Merge remote-tracking branch 'origin/feature/RIVS-31_Logical_database…
zalenskiSofteq Dec 17, 2024
0736188
Merge pull request #224 from RedisInsight/feature/RIVS-299_Add_featur…
vlad-dargel Dec 17, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
599 changes: 7 additions & 592 deletions .circleci/config.yml

Large diffs are not rendered by default.

610 changes: 610 additions & 0 deletions .circleci/config.yml.backup

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions .env
Original file line number Diff line number Diff line change
@@ -8,14 +8,14 @@ RI_APP_PORT=5541
RI_APP_VERSION='1.0.0'
RI_APP_PREFIX='api'
RI_APP_FOLDER_NAME='.redis-for-vscode'
RI_CDN_PATH='https://s3.amazonaws.com/redisinsight.download/public/releases/2.54.1/web-mini'
RI_CDN_PATH='https://s3.us-east-1.amazonaws.com/redisinsight.test/public/zalenski/vscode/web-mini'
RI_WITHOUT_BACKEND=false
# RI_WITHOUT_BACKEND=true
RI_STDOUT_LOGGER=false
RI_AUTO_BOOTSTRAP=false
RI_MIGRATE_OLD_FOLDERS=false
RI_BUILD_TYPE='VS_CODE'
RI_ENCRYPTION_KEYTAR=false
RI_ANALYTICS_START_EVENTS=true
RI_AGREEMENTS_PATH='../../webviews/resources/agreements-spec.json'
RI_ENCRYPTION_KEYTAR_SERVICE="redis-for-vscode"
# RI_SEGMENT_WRITE_KEY='SEGMENT_WRITE_KEY'
2 changes: 2 additions & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
@@ -86,6 +86,8 @@ module.exports = {
'function-paren-newline': 'off',
'prefer-regex-literals': 'off',
'react/display-name': 'off',
'react/jsx-indent-props': [2, 2],
'react/jsx-indent': [2, 2],
'no-promise-executor-return': 'off',
'import/order': [
1,
2 changes: 1 addition & 1 deletion .github/actions/install-all-build-libs/action.yml
Original file line number Diff line number Diff line change
@@ -8,7 +8,7 @@ runs:
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '20.15'
node-version: '20.18.0'

- name: Install dependencies for root package.js
shell: bash
4 changes: 2 additions & 2 deletions .github/workflows/pipeline-build-linux.yml
Original file line number Diff line number Diff line change
@@ -35,7 +35,7 @@ jobs:
uses: ./.github/actions/download-backend

- name: Set RI_SEGMENT_WRITE_KEY to .env file
run: echo "RI_SEGMENT_WRITE_KEY='$RI_SEGMENT_WRITE_KEY'" >> $envFile
run: echo "RI_SEGMENT_WRITE_KEY='${{ env.RI_SEGMENT_WRITE_KEY }}'" >> ${{ env.envFile }}

- name: Build linux package (production)
if: inputs.environment == 'production'
@@ -45,7 +45,7 @@ jobs:
- name: Build linux package (staging)
if: inputs.environment == 'staging'
run: |
sed -i "s/^RI_APP_FOLDER_NAME=.*/RI_APP_FOLDER_NAME='.redis-for-vscode-stage'/" $envFile
sed -i "s/^RI_APP_FOLDER_NAME=.*/RI_APP_FOLDER_NAME='.redis-for-vscode-stage'/" ${{ env.envFile }}
yarn package:stage --target linux-x64 --out ${packagePath}
- uses: actions/upload-artifact@v4
6 changes: 3 additions & 3 deletions .github/workflows/pipeline-build-macos.yml
Original file line number Diff line number Diff line change
@@ -28,7 +28,7 @@ jobs:
uses: ./.github/actions/install-all-build-libs

- name: Set RI_SEGMENT_WRITE_KEY to .env file
run: echo "RI_SEGMENT_WRITE_KEY='$RI_SEGMENT_WRITE_KEY'" >> $envFile
run: echo "RI_SEGMENT_WRITE_KEY='${{ env.RI_SEGMENT_WRITE_KEY }}'" >> ${{ env.envFile }}

- name: Download backend x64
uses: ./.github/actions/download-backend
@@ -38,7 +38,7 @@ jobs:
- name: Build macos x64 package (staging)
if: inputs.environment != 'production'
run: |
sed -i '' "s/^RI_APP_FOLDER_NAME=.*/RI_APP_FOLDER_NAME='.redis-for-vscode-stage'/" $envFile
sed -i '' "s/^RI_APP_FOLDER_NAME=.*/RI_APP_FOLDER_NAME='.redis-for-vscode-stage'/" ${{ env.envFile }}
yarn package:stage --target darwin-x64 --out ${packagePath}-x64.vsix
@@ -55,7 +55,7 @@ jobs:
- name: Build macos arm64 package (staging)
if: inputs.environment != 'production'
run: |
sed -i '' "s/^RI_APP_FOLDER_NAME=.*/RI_APP_FOLDER_NAME='.redis-for-vscode-stage'/" $envFile
sed -i '' "s/^RI_APP_FOLDER_NAME=.*/RI_APP_FOLDER_NAME='.redis-for-vscode-stage'/" ${{ env.envFile }}
yarn package:stage --target darwin-arm64 --out ${packagePath}-arm64.vsix
4 changes: 2 additions & 2 deletions .github/workflows/pipeline-build-windows.yml
Original file line number Diff line number Diff line change
@@ -25,7 +25,7 @@ jobs:
uses: ./.github/actions/download-backend

- name: Set RI_SEGMENT_WRITE_KEY to .env file
run: echo "RI_SEGMENT_WRITE_KEY='$RI_SEGMENT_WRITE_KEY'" >> $envFile
run: echo "RI_SEGMENT_WRITE_KEY='${{ env.RI_SEGMENT_WRITE_KEY }}'" >> ${{ env.envFile }}

- name: Build windows package (production)
if: inputs.environment == 'production'
@@ -35,7 +35,7 @@ jobs:
- name: Build windows package (staging)
if: inputs.environment == 'staging'
run: |
sed -i "s/^RI_APP_FOLDER_NAME=.*/RI_APP_FOLDER_NAME='.redis-for-vscode-stage'/" $envFile
sed -i "s/^RI_APP_FOLDER_NAME=.*/RI_APP_FOLDER_NAME='.redis-for-vscode-stage'/" ${{ env.envFile }}
yarn package:stage --target win32-x64 --out ${{ env.packagePath }}
- uses: actions/upload-artifact@v4
2 changes: 1 addition & 1 deletion .github/workflows/tests-e2e-linux.yml
Original file line number Diff line number Diff line change
@@ -28,7 +28,7 @@ jobs:
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '20.15'
node-version: '20.18.0'

- name: Download linux artifact
uses: actions/download-artifact@v4
47 changes: 32 additions & 15 deletions .github/workflows/tests-frontend.yml
Original file line number Diff line number Diff line change
@@ -3,7 +3,15 @@ on:
workflow_call:

env:
SLACK_AUDIT_REPORT_CHANNEL: ${{ secrets.SLACK_AUDIT_REPORT_CHANNEL }}
SLACK_AUDIT_REPORT_KEY: ${{ secrets.SLACK_AUDIT_REPORT_KEY }}
AWS_BUCKET_NAME_TEST: ${{ vars.AWS_BUCKET_NAME_TEST }}
AWS_DEFAULT_REGION: ${{ vars.AWS_DEFAULT_REGION }}
AWS_DISTRIBUTION_ID: ${{ secrets.AWS_DISTRIBUTION_ID }}
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
REPORT_NAME: "report-vscode-fe"
S3_PATH: "report-vscode-fe"

jobs:
unit-tests:
@@ -18,21 +26,30 @@ jobs:
- name: Unit tests UI
run: yarn test:cov

- name: Publish Test Results
uses: EnricoMi/publish-unit-test-result-action@v2
- name: Get current date
id: date
if: always()
with:
check_name: 'FE Unit tests summary'
comment_mode: 'failures'
files: reports/junit.xml
uses: RedisInsight/RedisInsight/.github/actions/get-current-date@873a0ebf55c85d3127bb4efb4d0636d9ab838226

- name: Generate test results
uses: dorny/test-reporter@v1

- name: Deploy 🚀
if: always()
run: |
GZIP_FILE=html.meta.json.gz
S3_SUB_PATH="test-reports/${{ steps.date.outputs.date }}/${{ github.run_id }}/${{ env.REPORT_NAME }}"
aws s3 cp report/ s3://${AWS_BUCKET_NAME_TEST}/public/${S3_SUB_PATH} --recursive --exclude "*.gz"
# s3 modified "gzip" content-type
# https://github.com/aws/aws-cli/issues/1131
aws s3 cp report/${GZIP_FILE} s3://${AWS_BUCKET_NAME_TEST}/public/${S3_SUB_PATH}/${GZIP_FILE} --content-type "application/x-gzip" --metadata-directive REPLACE
echo "S3_SUB_PATH=${S3_SUB_PATH}" >> $GITHUB_ENV
- name: Add link to report in the workflow summary
if: always()
with:
name: 'Test results: FE unit tests'
path: reports/junit.xml
reporter: jest-junit
list-tests: 'failed'
list-suites: 'failed'
fail-on-error: 'false'
run: |
link="${{ vars.DEFAULT_TEST_REPORTS_URL }}/${S3_SUB_PATH}/index.html"
echo "[${link}](${link})" >> $GITHUB_STEP_SUMMARY
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -20,6 +20,7 @@ coverage
/.nyc_output
**/coverage
reports
report

# IDEs and editors
/.idea
12 changes: 11 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -10,6 +10,9 @@
"source.fixAll.eslint": "explicit"
},
"eslint.validate": ["javascript", "typescript"],
"eslint.workingDirectories": [
{ "directory": "./", "changeProcessCWD": true }
],
"files.associations": {
"*.css": "postcss",
"*.scss": "postcss"
@@ -35,6 +38,7 @@
".eslintcache": true,
"bower_components": true,
"release": true,
"src/webviews/public": true,
"npm-debug.log.*": true,
"tests/**/__snapshots__": true,
"yarn.lock": true,
@@ -44,7 +48,13 @@
"**/pnpm-lock.yaml": true,
"**/test-extensions": true
},
"cSpell.words": ["githubocto", "tailwindcss", "webviews", "zustand"],
"cSpell.words": [
"githubocto",
"keyspace",
"tailwindcss",
"webviews",
"zustand"
],
"testing.automaticallyOpenPeekView": "never",
"[typescriptreact]": {
"editor.defaultFormatter": "dbaeumer.vscode-eslint"
2 changes: 2 additions & 0 deletions .vscodeignore
Original file line number Diff line number Diff line change
@@ -28,6 +28,8 @@ vite.config.mjs
# test
coverage
test
report
reports
tests
test-workspace
.vscode-test
5 changes: 1 addition & 4 deletions l10n/bundle.l10n.json
Original file line number Diff line number Diff line change
@@ -41,7 +41,7 @@
"Key Name": "Key Name",
" will be deleted.": " will be deleted.",
"Delete": "Delete",
"will be deleted from Redis for VS Code.": "will be deleted from Redis for VS Code.",
"will be removed from Redis for VS Code.": "will be removed from Redis for VS Code.",
"Key Size": "Key Size",
"Key Size: ": "Key Size: ",
"Length": "Length",
@@ -295,9 +295,6 @@
"Host:": "Host:",
"Database Index:": "Database Index:",
"Modules:": "Modules:",
"Select Logical Database": "Select Logical Database",
"Database Index": "Database Index",
"Enter Database Index": "Enter Database Index",
"No decompression": "No decompression",
"Enable automatic data decompression": "Enable automatic data decompression",
"Decompression format": "Decompression format",
8 changes: 5 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -143,6 +143,7 @@
"download:backend": "tsc ./scripts/downloadBackend.ts && node ./scripts/downloadBackend.js",
"dev": "vite dev",
"dev:key": "cross-env RI_DATA_ROUTE=main/key vite dev",
"dev:database": "cross-env RI_DATA_ROUTE=main/add_database vite dev",
"dev:sidebar": "cross-env RI_DATA_ROUTE=sidebar vite dev",
"l10n:collect": "npx @vscode/l10n-dev export -o ./l10n ./src",
"watch": "tsc -watch -p ./",
@@ -194,9 +195,9 @@
"@vitejs/plugin-react": "^4.2.1",
"@vitejs/plugin-react-swc": "^3.6.0",
"@vitest/coverage-v8": "^1.3.1",
"@vitest/ui": "^1.3.1",
"@vitest/ui": "^1.6.0",
"@vscode/l10n-dev": "^0.0.35",
"@vscode/vsce": "^3.0.0",
"@vscode/vsce": "^3.2.1",
"cross-env": "^7.0.3",
"csv-parser": "^3.0.0",
"csv-stringify": "^6.5.1",
@@ -236,6 +237,7 @@
"ts-node": "^10.9.1",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.4.5",
"upath": "^2.0.1",
"uuid": "^9.0.1",
"vite": "^5.2.10",
"vite-plugin-react-click-to-component": "^3.0.0",
@@ -286,7 +288,7 @@
"react-inlinesvg": "^4.1.1",
"react-monaco-editor": "^0.55.0",
"react-router-dom": "^6.17.0",
"react-select": "^5.8.0",
"react-select": "^5.8.3",
"react-spinners": "^0.13.8",
"react-virtualized": "^9.22.5",
"react-virtualized-auto-sizer": "^1.0.20",
19 changes: 18 additions & 1 deletion scripts/downloadBackend.ts
Original file line number Diff line number Diff line change
@@ -3,8 +3,10 @@ import * as fs from 'fs'
import * as path from 'path'
import * as cp from 'child_process'
import * as dotenv from 'dotenv'
import * as upath from 'upath'
import { parse as parseUrl } from 'url'


dotenv.config({
path: [
path.join(__dirname, '..', '.env'),
@@ -80,13 +82,28 @@ async function downloadRedisBackendArchive(
})
}

function getNormalizedString(string: string) {
return string?.startsWith('D:')
? upath.normalize(string).replace('D:', '/d')
: string
}

function unzipRedisServer(redisInsideArchivePath: string, extractDir: string) {
// tar does not create extractDir by default
if (!fs.existsSync(extractDir)) {
fs.mkdirSync(extractDir)
}

cp.spawnSync('tar', ['-xf', redisInsideArchivePath, '-C', extractDir, '--strip-components', '1', 'api'])
cp.spawnSync('tar', [
'-xf',
getNormalizedString(redisInsideArchivePath),
'-C',
getNormalizedString(extractDir),
'--strip-components',
'1',
'api',
])


// remove tutorials
fs.rmSync(tutorialsPath, { recursive: true, force: true });
2 changes: 1 addition & 1 deletion src/extension.ts
Original file line number Diff line number Diff line change
@@ -164,7 +164,7 @@ export async function activate(context: vscode.ExtensionContext) {

vscode.commands.registerCommand('RedisForVSCode.editDatabaseClose', (args) => {
WebviewPanel.getInstance({ viewId: ViewId.EditDatabase }).dispose()
sidebarProvider.view?.webview.postMessage({ action: 'RefreshTree', data: args })
sidebarProvider.view?.webview.postMessage({ action: 'UpdateDatabaseInList', data: args })

const keyDetailsWebview = WebviewPanel.instances[ViewId.Key]
if (keyDetailsWebview) {
33 changes: 32 additions & 1 deletion src/resources/agreements-spec.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"version": "1.0.1",
"version": "1.0.2",
"agreements": {
"analytics": {
"defaultValue": false,
@@ -35,6 +35,37 @@
"title": "Server Side Public License",
"label": "I have read and understood the Terms",
"requiredText": "Accept the Server Side Public License"
},
"encryption": {
"conditional": true,
"checker": "KEYTAR",
"defaultOption": "false",
"options": {
"true": {
"defaultValue": true,
"displayInSetting": false,
"required": false,
"editable": true,
"disabled": false,
"category": "privacy",
"since": "1.0.2",
"title": "Encryption",
"label": "Encrypt sensitive information",
"description": "Select to encrypt sensitive information using system keychain. Otherwise, this information is stored locally in plain text, which may incur security risk."
},
"false": {
"defaultValue": false,
"displayInSetting": false,
"required": false,
"editable": true,
"disabled": true,
"category": "privacy",
"since": "1.0.2",
"title": "Encryption",
"label": "Encrypt sensitive information",
"description": "Install or enable the system keychain to encrypt and securely store your sensitive information added before using the application. Otherwise, this information will be stored locally in plain text and may lead to security risks."
}
}
}
}
}
4 changes: 3 additions & 1 deletion src/webviews/src/actions/processCliAction.ts
Original file line number Diff line number Diff line change
@@ -4,9 +4,11 @@ import { useDatabasesStore } from 'uiSrc/store'

export const processCliAction = (message: CliAction) => {
const prevDatabaseId = useDatabasesStore.getState().connectedDatabase?.id
const prevDatabaseIndex = useDatabasesStore.getState().connectedDatabase?.db
const database = message?.data?.database
const dbIndex = database?.db ?? 0

if (prevDatabaseId === database?.id) {
if (prevDatabaseId! + prevDatabaseIndex === database?.id + dbIndex) {
return
}
window.ri.database = database
158 changes: 158 additions & 0 deletions src/webviews/src/components/auto-refresh/AutoRefresh.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import React from 'react'
import { instance, mock } from 'ts-mockito'
import { fireEvent, screen, render, act } from 'testSrc/helpers'
import { AutoRefresh, Props } from './AutoRefresh'
import { DEFAULT_REFRESH_RATE } from './utils'

const mockedProps = mock<Props>()

const INLINE_ITEM_EDITOR = 'inline-item-editor'

describe('AutoRefresh', () => {
it('should render', () => {
expect(render(<AutoRefresh {...instance(mockedProps)} />)).toBeTruthy()
})

it('prop "displayText = true" should show Refresh text', () => {
const { queryByTestId } = render(<AutoRefresh {...instance(mockedProps)} displayText />)

expect(queryByTestId('refresh-message-label')).toBeInTheDocument()
})

it('prop "displayText = false" should hide Refresh text', () => {
const { queryByTestId } = render(<AutoRefresh {...instance(mockedProps)} displayText={false} />)

expect(queryByTestId('refresh-message-label')).not.toBeInTheDocument()
})

it('should call onRefresh', () => {
const onRefresh = vi.fn()
render(<AutoRefresh {...instance(mockedProps)} onRefresh={onRefresh} />)

fireEvent.click(screen.getByTestId('refresh-btn'))
expect(onRefresh).toBeCalled()
})

it('refresh text should contain "Last refresh" time with disabled auto-refresh', async () => {
render(<AutoRefresh {...instance(mockedProps)} displayText />)

expect(screen.getByTestId('refresh-message-label')).toHaveTextContent(/Last refresh:/i)
expect(screen.getByTestId('refresh-message')).toHaveTextContent('now')
})

it('refresh text should contain "Auto-refresh" time with enabled auto-refresh', async () => {
render(<AutoRefresh {...instance(mockedProps)} displayText />)

fireEvent.click(screen.getByTestId('auto-refresh-config-btn'))
fireEvent.click(screen.getByTestId('auto-refresh-switch'))

expect(screen.getByTestId('refresh-message-label')).toHaveTextContent(/Auto refresh:/i)
expect(screen.getByTestId('refresh-message')).toHaveTextContent(DEFAULT_REFRESH_RATE)
})

it('should locate refresh message label when testid is provided', () => {
render(<AutoRefresh {...instance(mockedProps)} displayText testid="testid" />)

expect(screen.getByTestId('testid-refresh-message-label')).toBeInTheDocument()
})

it('should locate refresh message when testid is provided', () => {
render(<AutoRefresh {...instance(mockedProps)} displayText testid="testid" />)

expect(screen.getByTestId('testid-refresh-message')).toBeInTheDocument()
})

it('should locate refresh button when testid is provided', () => {
render(<AutoRefresh {...instance(mockedProps)} testid="testid" />)

expect(screen.getByTestId('testid-refresh-btn')).toBeInTheDocument()
})

it('should locate auto-refresh config button when testid is provided', () => {
render(<AutoRefresh {...instance(mockedProps)} testid="testid" />)

expect(screen.getByTestId('testid-auto-refresh-config-btn')).toBeInTheDocument()
})

it('should locate auto-refresh switch when testid is provided', () => {
render(<AutoRefresh {...instance(mockedProps)} testid="testid" />)

fireEvent.click(screen.getByTestId('testid-auto-refresh-config-btn'))
expect(screen.getByTestId('testid-auto-refresh-switch')).toBeInTheDocument()
})

describe('AutoRefresh Config', () => {
it('Auto refresh config should render', () => {
const { queryByTestId } = render(<AutoRefresh {...instance(mockedProps)} />)

fireEvent.click(screen.getByTestId('auto-refresh-config-btn'))
expect(queryByTestId('auto-refresh-switch')).toBeInTheDocument()
})

it('should call onRefresh after enable auto-refresh and set 1 sec', async () => {
const onRefresh = vi.fn()
render(<AutoRefresh {...instance(mockedProps)} onRefresh={onRefresh} />)

fireEvent.click(screen.getByTestId('auto-refresh-config-btn'))
fireEvent.click(screen.getByTestId('auto-refresh-switch'))
fireEvent.click(screen.getByTestId(INLINE_ITEM_EDITOR))

fireEvent.input(screen.getByTestId(INLINE_ITEM_EDITOR), { target: { value: '1' } })
expect(screen.getByTestId(INLINE_ITEM_EDITOR)).toHaveValue('1')

screen.getByTestId(/apply-btn/).click()

await act(async () => {
await new Promise((r) => setTimeout(r, 1300))
})
expect(onRefresh).toBeCalledTimes(1)

await act(async () => {
await new Promise((r) => setTimeout(r, 1300))
})
expect(onRefresh).toBeCalledTimes(2)

await act(async () => {
await new Promise((r) => setTimeout(r, 1300))
})
expect(onRefresh).toBeCalledTimes(3)
})
})

it('should NOT call onRefresh with disabled state', async () => {
const onRefresh = vi.fn()
const { rerender } = render(<AutoRefresh {...instance(mockedProps)} onRefresh={onRefresh} />)

fireEvent.click(screen.getByTestId('auto-refresh-config-btn'))
fireEvent.click(screen.getByTestId('auto-refresh-switch'))
fireEvent.click(screen.getByTestId(INLINE_ITEM_EDITOR))
fireEvent.input(screen.getByTestId(INLINE_ITEM_EDITOR), { target: { value: '1' } })

expect(screen.getByTestId(INLINE_ITEM_EDITOR)).toHaveValue('1')

screen.getByTestId(/apply-btn/).click()

await act(() => {
rerender(<AutoRefresh {...instance(mockedProps)} onRefresh={onRefresh} disabled />)
})

await act(async () => {
await new Promise((r) => setTimeout(r, 1300))
})
expect(onRefresh).toBeCalledTimes(0)

await act(async () => {
await new Promise((r) => setTimeout(r, 1300))
})
expect(onRefresh).toBeCalledTimes(0)

await act(() => {
rerender(<AutoRefresh {...instance(mockedProps)} onRefresh={onRefresh} disabled={false} />)
})

await act(async () => {
await new Promise((r) => setTimeout(r, 1300))
})
expect(onRefresh).toBeCalledTimes(1)
})
})
248 changes: 248 additions & 0 deletions src/webviews/src/components/auto-refresh/AutoRefresh.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
import React, { useEffect, useState } from 'react'
import * as l10n from '@vscode/l10n'
import Popover from 'reactjs-popup'
import cx from 'classnames'
import { VscChevronDown, VscRefresh } from 'react-icons/vsc'

import {
MIN_REFRESH_RATE,
StorageItem,
} from 'uiSrc/constants'
import {
errorValidateRefreshRateNumber,
validateRefreshRateNumber,
} from 'uiSrc/utils'
import { InlineEditor } from 'uiSrc/components'
import { localStorageService } from 'uiSrc/services'
import { Nullable } from 'uiSrc/interfaces'
import { Checkbox, RiButton, Tooltip } from 'uiSrc/ui'
import {
getTextByRefreshTime,
DEFAULT_REFRESH_RATE,
DURATION_FIRST_REFRESH_TIME,
MINUTE,
NOW,
} from './utils'

import styles from './styles.module.scss'

export interface Props {
postfix: string
loading: boolean
displayText?: boolean
lastRefreshTime: Nullable<number>
testid?: string
containerClassName?: string
turnOffAutoRefresh?: boolean
onRefresh: (enableAutoRefresh: boolean) => void
onRefreshClicked?: () => void
onEnableAutoRefresh?: (enableAutoRefresh: boolean, refreshRate: string) => void
onChangeAutoRefreshRate?: (enableAutoRefresh: boolean, refreshRate: string) => void
disabled?: boolean
enableAutoRefreshDefault?: boolean
}

const TIMEOUT_TO_UPDATE_REFRESH_TIME = 1_000 * MINUTE // once a minute

const AutoRefresh = React.memo(({
postfix,
loading,
displayText = true,
lastRefreshTime,
containerClassName = '',
testid = '',
turnOffAutoRefresh,
onRefresh,
onRefreshClicked,
onEnableAutoRefresh,
onChangeAutoRefreshRate,
disabled,
enableAutoRefreshDefault = false,
}: Props) => {
let intervalText: NodeJS.Timeout
let intervalRefresh: NodeJS.Timeout

const [refreshMessage, setRefreshMessage] = useState(NOW)
const [isPopoverOpen, setIsPopoverOpen] = useState(false)
const [refreshRate, setRefreshRate] = useState<string>('')
const [refreshRateMessage, setRefreshRateMessage] = useState<string>('')
const [enableAutoRefresh, setEnableAutoRefresh] = useState(enableAutoRefreshDefault)

const onButtonClick = () => setIsPopoverOpen((isPopoverOpen) => !isPopoverOpen)
const closePopover = () => {
setEnableAutoRefresh(enableAutoRefresh)
setIsPopoverOpen(false)
}

useEffect(() => {
const refreshRateStorage = localStorageService.get(StorageItem.autoRefreshRate + postfix)
|| DEFAULT_REFRESH_RATE

setRefreshRate(refreshRateStorage)
}, [postfix])

useEffect(() => {
if (turnOffAutoRefresh && enableAutoRefresh) {
setEnableAutoRefresh(false)
clearInterval(intervalRefresh)
}
}, [turnOffAutoRefresh])

// update refresh label text
useEffect(() => {
const delta = getLastRefreshDelta(lastRefreshTime)
updateLastRefresh()

intervalText = setInterval(() => {
if (document.hidden) return

updateLastRefresh()
}, delta < DURATION_FIRST_REFRESH_TIME ? DURATION_FIRST_REFRESH_TIME : TIMEOUT_TO_UPDATE_REFRESH_TIME)
return () => clearInterval(intervalText)
}, [lastRefreshTime])

// refresh interval
useEffect(() => {
updateLastRefresh()

if (enableAutoRefresh && !loading && !disabled) {
intervalRefresh = setInterval(() => {
if (document.hidden) return

handleRefresh()
}, +refreshRate * 1_000)
} else {
clearInterval(intervalRefresh)
}

if (enableAutoRefresh) {
updateAutoRefreshText(refreshRate)
}

return () => clearInterval(intervalRefresh)
}, [enableAutoRefresh, refreshRate, loading, disabled, lastRefreshTime])

const getLastRefreshDelta = (time:Nullable<number>) => (Date.now() - (time || 0)) / 1_000

const getDataTestid = (suffix: string) => (testid ? `${testid}-${suffix}` : suffix)

const updateLastRefresh = () => {
const delta = getLastRefreshDelta(lastRefreshTime)
const text = getTextByRefreshTime(delta, lastRefreshTime ?? 0)

lastRefreshTime && setRefreshMessage(text)
}

const updateAutoRefreshText = (refreshRate: string) => {
enableAutoRefresh && setRefreshRateMessage(
// more than 1 minute
+refreshRate > MINUTE ? `${Math.floor(+refreshRate / MINUTE)} min` : `${refreshRate} s`,
)
}

const handleApplyAutoRefreshRate = (initValue: string) => {
const value = +initValue >= MIN_REFRESH_RATE ? initValue : `${MIN_REFRESH_RATE}`
setRefreshRate(value)
localStorageService.set(StorageItem.autoRefreshRate + postfix, value)
onChangeAutoRefreshRate?.(enableAutoRefresh, value)
}

const handleRefresh = () => {
onRefresh(enableAutoRefresh)
}

const handleRefreshClick = () => {
handleRefresh()
onRefreshClicked?.()
}

const onChangeEnableAutoRefresh = (value: boolean) => {
setEnableAutoRefresh(value)

onEnableAutoRefresh?.(value, refreshRate)
}

return (
<div className={cx(
styles.container,
containerClassName,
{
[styles.enable]: !disabled && enableAutoRefresh,
'opacity-70': disabled,
},
)}>
<span className={styles.summary}>
{displayText && (
<span data-testid={getDataTestid('refresh-message-label')}>{enableAutoRefresh ? l10n.t('Auto refresh:') : l10n.t('Last refresh:')}</span>
)}
{displayText && (<span className={cx('refresh-message-time', styles.time, { [styles.disabled]: disabled })} data-testid={getDataTestid('refresh-message')}>
{` ${enableAutoRefresh ? refreshRateMessage : refreshMessage}`}
</span>)}
</span>

<Tooltip
title={l10n.t('Last Refresh')}
position="bottom center"
className={styles.tooltip}
content={refreshMessage}
>
<RiButton
disabled={loading || disabled}
onClick={handleRefreshClick}
onMouseEnter={updateLastRefresh}
className={cx('auto-refresh-btn', styles.btn, { [styles.rolling]: !disabled && enableAutoRefresh })}
aria-labelledby={getDataTestid('refresh-btn')?.replaceAll?.('-', ' ')}
data-testid={getDataTestid('refresh-btn')}
>
<VscRefresh />
</RiButton>
</Tooltip>

<Popover
repositionOnResize
keepTooltipInside={false}
open={isPopoverOpen}
position="bottom right"
className="popover-auto-refresh"
onClose={closePopover}
trigger={(
<RiButton
disabled={disabled}
aria-label="Auto-refresh config popover"
className={cx(styles.anchorBtn, styles.anchorWrapper, { [styles.anchorBtnOpen]: isPopoverOpen })}
onClick={onButtonClick}
data-testid={getDataTestid('auto-refresh-config-btn')}
>
<VscChevronDown />
</RiButton>
)}
>
<div className={styles.switch}>
<Checkbox
checked={enableAutoRefresh}
onChange={(e) => onChangeEnableAutoRefresh(e.target.checked)}
data-testid={getDataTestid('auto-refresh-switch')}
className={styles.switchOption}
labelText={l10n.t('Auto Refresh')}
/>
</div>
<div className={styles.inputContainer}>
<div className={styles.inputLabel}>Refresh rate:</div>
<div className={styles.input} data-testid={getDataTestid('auto-refresh-rate-input')}>
<InlineEditor
initialValue={refreshRate}
fieldName="refreshRate"
placeholder={DEFAULT_REFRESH_RATE}
loading={loading}
validation={validateRefreshRateNumber}
disableByValidation={errorValidateRefreshRateNumber}
onApply={(value) => handleApplyAutoRefreshRate(value)}
/>
</div>
</div>
</Popover>
</div>
)
})

export { AutoRefresh }
61 changes: 61 additions & 0 deletions src/webviews/src/components/auto-refresh/styles.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
.container {
@apply flex justify-center items-center relative whitespace-nowrap;
}

.btn {
transition: transform 0.3s ease;

&.rolling svg {
color: var(--vscode-button-background) !important;
}
}

.time {
@apply pr-1.5;

&.disabled {
@apply opacity-50;
}
}


:global(.popover-auto-refresh-content) {
@apply max-w-[225px] w-[225px] #{!important};

}

.switch {
@apply pb-4;
}

.input {
@apply h-[30px] inline-block w-[90px];

input {
@apply h-[30px];
}
}
.inputContainer {
@apply h-[30px];
}

.inputLabel {
@apply inline-block w-20;
}

.anchorBtn {
@apply min-h-[14px] min-w-[14px] -ml-[3px];

svg {
@apply w-[10px] h-[10px];
}
}

.enable {
svg {
fill: var(--vscode-button-background) !important;
}
.time {
color: var(--vscode-button-background) !important;
}
}
23 changes: 23 additions & 0 deletions src/webviews/src/components/auto-refresh/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import * as l10n from '@vscode/l10n'
import { truncateNumberToFirstUnit } from 'uiSrc/utils'

export const NOW = l10n.t('now')
export const MINUTE = 60
export const DURATION_FIRST_REFRESH_TIME = 5
export const DEFAULT_REFRESH_RATE = '5.0'

export const getTextByRefreshTime = (delta: number, lastRefreshTime: number) => {
let text = ''

if (delta > MINUTE) {
text = truncateNumberToFirstUnit((Date.now() - (lastRefreshTime || 0)) / 1_000)
}
if (delta < MINUTE) {
text = '< 1 min'
}
if (delta < DURATION_FIRST_REFRESH_TIME) {
text = NOW
}

return text
}
69 changes: 0 additions & 69 deletions src/webviews/src/components/database-form/DbIndex.tsx

This file was deleted.

103 changes: 67 additions & 36 deletions src/webviews/src/components/database-form/TlsDetails.tsx
Original file line number Diff line number Diff line change
@@ -2,40 +2,61 @@ import React, { ChangeEvent, useId } from 'react'
import cx from 'classnames'
import { FormikProps } from 'formik'
import * as l10n from '@vscode/l10n'
import { VSCodeDivider } from '@vscode/webview-ui-toolkit/react'
import { CheckboxChangeEvent } from 'rc-checkbox'
import { find } from 'lodash'
import { MenuListProps } from 'react-select'

import { validateCertName, validateField } from 'uiSrc/utils'
import { sendEventTelemetry, TelemetryEvent, validateCertName, validateField } from 'uiSrc/utils'
import {
ADD_NEW,
ADD_NEW_CA_CERT,
ADD_NEW_CA_CERT_LABEL,
ADD_NEW_LABEL,
ApiEndpoints,
NO_CA_CERT,
NO_CA_CERT_LABEL,
} from 'uiSrc/constants'
import { DbConnectionInfo } from 'uiSrc/interfaces'
import { Checkbox, InputText, Select, TextArea } from 'uiSrc/ui'
import { DbConnectionInfo, RedisString } from 'uiSrc/interfaces'
import { Checkbox, InputText, TextArea } from 'uiSrc/ui'
import { removeCertAction } from 'uiSrc/store'
import { SuperSelectRemovableOption, SuperSelect, SuperSelectOption } from 'uiSrc/components'
import styles from './styles.module.scss'

const suffix = '_tls_details'

export interface Props {
formik: FormikProps<DbConnectionInfo>
caCertificates?: { id: string, name: string }[]
certificates?: { id: string, name: string }[]
}

const TlsDetails = (props: Props) => {
const { formik, caCertificates, certificates } = props
const id = useId()

const optionsCertsCA = [
{
value: NO_CA_CERT,
label: NO_CA_CERT_LABEL,
},
{
value: ADD_NEW_CA_CERT,
label: ADD_NEW_CA_CERT_LABEL,
},
const handleDeleteCaCert = (id: RedisString, onSuccess?: () => void) => {
removeCertAction(id, ApiEndpoints.CA_CERTIFICATES, () => {
onSuccess?.()
handleClickDeleteCert('CA')
})
}

const handleDeleteClientCert = (id: RedisString, onSuccess?: () => void) => {
removeCertAction(id, ApiEndpoints.CLIENT_CERTIFICATES, () => {
onSuccess?.()
handleClickDeleteCert('Client')
})
}

const handleClickDeleteCert = (certificateType: 'Client' | 'CA') => {
sendEventTelemetry({
event: TelemetryEvent.CONFIG_DATABASES_CERTIFICATE_REMOVED,
eventData: {
certificateType,
},
})
}

const optionsCertsCA: SuperSelectOption[] = [
NO_CA_CERT,
ADD_NEW_CA_CERT,
]

caCertificates?.forEach((cert) => {
@@ -45,12 +66,7 @@ const TlsDetails = (props: Props) => {
})
})

const optionsCertsClient = [
{
value: ADD_NEW,
label: ADD_NEW_LABEL,
},
]
const optionsCertsClient: SuperSelectOption[] = [ADD_NEW]

certificates?.forEach((cert) => {
optionsCertsClient.push({
@@ -130,25 +146,30 @@ const TlsDetails = (props: Props) => {
<div className="w-[100px]">
{`${l10n.t('CA Certificate')}${formik.values.verifyServerTlsCert ? '*' : ''}`}
</div>
<Select
// name="selectedCaCertName"
// placeholder="Select CA certificate"
<SuperSelect
containerClassName="database-form-select w-[256px]"
itemClassName="database-form-select__option"
idSelected={formik.values.selectedCaCertName ?? NO_CA_CERT}
selectedOption={find(optionsCertsCA, { value: formik.values.selectedCaCertName }) as SuperSelectOption ?? NO_CA_CERT}
options={optionsCertsCA}
onChange={(value) => {
components={{ MenuList: (props: MenuListProps<SuperSelectOption, false>) => (
<SuperSelectRemovableOption
{...props}
suffix={suffix}
countDefaultOptions={2}
onDeleteOption={handleDeleteCaCert}
/>
) }}
onChange={(option) => {
formik.setFieldValue(
'selectedCaCertName',
value || NO_CA_CERT,
option?.value || NO_CA_CERT?.value,
)
}}
testid="select-ca-cert"
/>
</div>

{formik.values.tls
&& formik.values.selectedCaCertName === ADD_NEW_CA_CERT && (
&& formik.values.selectedCaCertName === ADD_NEW_CA_CERT.value && (
<div className="mb-3">
<InputText
name="newCaCertName"
@@ -170,7 +191,7 @@ const TlsDetails = (props: Props) => {
</div>

{formik.values.tls
&& formik.values.selectedCaCertName === ADD_NEW_CA_CERT && (
&& formik.values.selectedCaCertName === ADD_NEW_CA_CERT.value && (
<div>
<TextArea
name="newCaCert"
@@ -203,13 +224,23 @@ const TlsDetails = (props: Props) => {
<div>
<div className="flex items-center pb-3">
<div className="w-[100px]">{l10n.t('Client Certificate*')}</div>
<Select
<SuperSelect
containerClassName="database-form-select w-[256px]"
itemClassName="database-form-select__option"
selectedOption={find(optionsCertsClient, { value: formik.values.selectedTlsClientCertId }) as SuperSelectOption ?? ADD_NEW}
options={optionsCertsClient}
idSelected={formik.values.selectedTlsClientCertId ?? ADD_NEW}
onChange={(value) => {
formik.setFieldValue('selectedTlsClientCertId', value)
components={{ MenuList: (props: MenuListProps<SuperSelectOption, false>) => (
<SuperSelectRemovableOption
{...props}
countDefaultOptions={1}
suffix={suffix}
onDeleteOption={handleDeleteClientCert}
/>
) }}
onChange={(option) => {
formik.setFieldValue(
'selectedTlsClientCertId',
option?.value || ADD_NEW?.value,
)
}}
testid="select-cert"
/>
1 change: 0 additions & 1 deletion src/webviews/src/components/database-form/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
export { DbInfo } from './DbInfo'
export { MessageStandalone } from './Messages'
export { DbIndex } from './DbIndex'
export { DbCompressor } from './DbCompressor'
export { TlsDetails } from './TlsDetails'
export { DatabaseForm } from './DatabaseForm'
5 changes: 5 additions & 0 deletions src/webviews/src/components/index.ts
Original file line number Diff line number Diff line change
@@ -10,6 +10,11 @@ export { FieldMessage } from './field-message/FieldMessage'
export { NoDatabases } from './no-databases/NoDatabases'
export { MonacoLanguages } from './monaco-languages/MonacoLanguages'
export { UploadFile } from './upload-file/UploadFile'
export { SuperSelect } from './super-select/SuperSelect'
export { SuperSelectRemovableOption } from './super-select/components/removable-option/RemovableOption'
export { AutoRefresh } from './auto-refresh/AutoRefresh'
export * from './database-form'
export * from './consents-option'
export * from './consents-privacy'

export type { SuperSelectOption } from './super-select/SuperSelect'
9 changes: 7 additions & 2 deletions src/webviews/src/components/no-keys-message/NoKeysMessage.tsx
Original file line number Diff line number Diff line change
@@ -3,7 +3,7 @@ import { VSCodeButton } from '@vscode/webview-ui-toolkit/react'
import React, { FC } from 'react'

import { VscodeMessageAction } from 'uiSrc/constants'
import { Nullable } from 'uiSrc/interfaces'
import { Maybe, Nullable } from 'uiSrc/interfaces'
import { vscodeApi } from 'uiSrc/services'
import { Database } from 'uiSrc/store'
import { sendEventTelemetry, TelemetryEvent } from 'uiSrc/utils'
@@ -13,20 +13,25 @@ import styles from './styles.module.scss'
export interface Props {
total: Nullable<number>
database: Database
dbIndex: Maybe<number>
}

export const NoKeysMessage: FC<Props> = (props) => {
const {
total,
database,
dbIndex,
} = props

const handleAddKey = () => {
sendEventTelemetry({
event: TelemetryEvent.TREE_VIEW_KEY_ADD_BUTTON_CLICKED,
eventData: { databaseId: database.id },
})
vscodeApi.postMessage({ action: VscodeMessageAction.AddKey, data: { database } })
vscodeApi.postMessage({
action: VscodeMessageAction.AddKey,
data: { database: { ...database, db: dbIndex } },
})
}

// TODO: will be implemented in the future
Original file line number Diff line number Diff line change
@@ -66,6 +66,7 @@ const PopoverDelete = (props: Props) => {
<Popup
key={item}
ref={ref}
nested={false}
closeOnEscape
closeOnDocumentClick
repositionOnResize
2 changes: 1 addition & 1 deletion src/webviews/src/components/scan-more/styles.module.scss
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
.container {
@apply flex flex-row pl-[15px];
@apply flex flex-row pl-[23px];

&:hover {
background: var(--vscode-merge-commonHeaderBackground);
43 changes: 43 additions & 0 deletions src/webviews/src/components/super-select/SuperSelect.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import React from 'react'
import { instance, mock } from 'ts-mockito'

import { render, constants, fireEvent, waitFor } from 'testSrc/helpers'
import { SuperSelect, Props } from './SuperSelect'

const mockedProps = mock<Props>()
const testIdMock = 'my-select-component'

describe('SuperSelect', () => {
it('should render', async () => {
expect(render(
<SuperSelect {...instance(mockedProps)} options={constants.SUPER_SELECT_OPTIONS} />),
).toBeTruthy()
})

it('should call onChange when the first option is selected', async () => {
const mockedOnChange = vi.fn()
const mockedLabel = constants.SUPER_SELECT_OPTIONS?.[0].label ?? ''
const mockedValue = constants.SUPER_SELECT_OPTIONS?.[0].value ?? ''

const { getByText, queryByTestId } = render(<SuperSelect
options={constants.SUPER_SELECT_OPTIONS}
onChange={mockedOnChange}
testid={testIdMock}
/>)

const mySelectComponent = queryByTestId('my-select-component')

expect(mySelectComponent).toBeDefined()
expect(mySelectComponent).not.toBeNull()
expect(mockedOnChange).toHaveBeenCalledTimes(0)

fireEvent.keyDown(mySelectComponent?.firstChild!, { key: 'ArrowDown' })
await waitFor(() => getByText(mockedLabel))
fireEvent.click(getByText(mockedLabel))

expect(mockedOnChange).toHaveBeenCalledTimes(1)
expect(mockedOnChange).toHaveBeenCalledWith(
{ label: mockedLabel, value: mockedValue },
{ action: 'select-option', name: undefined, option: undefined })
})
})
51 changes: 51 additions & 0 deletions src/webviews/src/components/super-select/SuperSelect.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import React, { FC } from 'react'
import cx from 'classnames'
import Select, { Props as SelectProps } from 'react-select'

import { Maybe } from 'uiSrc/interfaces'
import styles from './styles.module.scss'

export interface SuperSelectOption {
value: string
label: string
testid?: string
}

export interface Props extends SelectProps<SuperSelectOption, false> {
selectedOption?: Maybe<SuperSelectOption>
containerClassName?: string
itemClassName?: string
testid?: string
}

const SuperSelect: FC<Props> = (props) => {
const {
selectedOption,
containerClassName,
testid,
} = props

return (
<div className={cx(styles.container, containerClassName)} data-testid={testid}>
<Select<SuperSelectOption>
{...props}
closeMenuOnSelect
closeMenuOnScroll
isSearchable={false}
isMulti={false}
value={selectedOption}
classNames={{
container: () => styles.selectContainer,
control: () => styles.control,
option: ({ isSelected }) => cx(styles.option, { [styles.optionSelected]: isSelected }),
singleValue: () => styles.singleValue,
menu: () => styles.menu,
indicatorsContainer: () => styles.indicatorsContainer,
indicatorSeparator: () => 'hidden',
}}
/>
</div>
)
}

export { SuperSelect }
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import React from 'react'
import { instance, mock } from 'ts-mockito'

import { render, constants } from 'testSrc/helpers'
import { SuperSelectRemovableOption, Props } from './RemovableOption'

const mockedProps = mock<Props>()

describe('SuperSelectRemovableOption', () => {
it('should render', async () => {
expect(render(<SuperSelectRemovableOption {...instance(mockedProps)} options={constants.SUPER_SELECT_OPTIONS} />)).toBeTruthy()
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import React, { Children, useCallback, useState } from 'react'
import { MenuListProps } from 'react-select'
import cx from 'classnames'
import * as l10n from '@vscode/l10n'

import { PopoverDelete, SuperSelectOption } from 'uiSrc/components'
import { Maybe, RedisString } from 'uiSrc/interfaces'

import styles from '../../styles.module.scss'

export interface Props extends MenuListProps<SuperSelectOption, false> {
suffix: string
countDefaultOptions: number
onDeleteOption: (id: RedisString, onSuccess: () => void) => void
}

const SuperSelectRemovableOption = (props: Props) => {
const { suffix, countDefaultOptions = 0, options, children, getValue, selectOption, onDeleteOption } = props

const [activeOptionId, setActiveOptionId] = useState<Maybe<string>>(undefined)

const showPopover = useCallback((id = '') => {
setActiveOptionId(`${id}${suffix}`)
}, [])

const handleRemoveOption = (id: RedisString) => {
onDeleteOption(id, () => {
const selectedValue = getValue()?.[0]
setActiveOptionId(undefined)

// reset selected option if removed value is selected
if (selectedValue?.value === id) {
selectOption(options?.[0] as SuperSelectOption)
} else {
selectOption(selectedValue)
}
})
}

return (
<div>
{Children.map(children, (child, i) => (
<div key={(options[i] as SuperSelectOption).value} className={cx(styles.option, 'flex justify-between items-center relative')}>
{child}
{i + 1 > countDefaultOptions && <PopoverDelete
header={`${(options[i] as SuperSelectOption).label}`}
text={l10n.t('will be removed from Redis for VS Code.')}
item={(options[i] as SuperSelectOption).value}
suffix={suffix}
triggerClassName='absolute right-2.5'
position='right center'
deleting={activeOptionId}
showPopover={showPopover}
handleDeleteItem={handleRemoveOption}
testid={`delete-option-${(options[i] as SuperSelectOption).value}`}
/>}
</div>
))}
</div>
)
}

export { SuperSelectRemovableOption }
38 changes: 38 additions & 0 deletions src/webviews/src/components/super-select/styles.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
.container {
@apply min-w-[210px];
}

.selectContainer {
@apply min-h-8 h-8 #{!important};
}

.control {
@apply bg-vscode-dropdown-background border-solid border border-vscode-input-border rounded-none min-h-8 h-8 cursor-pointer #{!important};
}

.option {
@apply bg-vscode-input-background cursor-pointer #{!important};

&:hover {
background-color: var(--vscode-sideBarTitle-background) !important;
}

&Selected {
@apply text-vscode-foreground #{!important};
background-color: var(--vscode-sideBarTitle-background) !important;
}
}

.indicatorsContainer {
@apply min-h-8 h-8 #{!important};
}

.menu {
@apply mt-0 max-h-[200px] rounded-none overflow-auto #{!important};
}

.singleValue {
@apply text-vscode-foreground #{!important};
}


37 changes: 37 additions & 0 deletions src/webviews/src/constants/cli/cliOutputComponents.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { describe, it, expect } from 'vitest'
import React from 'react'

import { render } from 'testSrc/helpers'
import { InitOutputText, cliTexts } from './cliOutputComponents'

describe('InitOutputText', () => {
it('should render correct output with valid inputs', () => {
const host = '127.0.0.1'
const port = 6379
const dbIndex = 1
const { container } = render(<>{InitOutputText(host, port, dbIndex, false)}</>)

expect(container).toHaveTextContent('Connecting...')
expect(container).toHaveTextContent('Pinging Redis server on ')
expect(container).toHaveTextContent(`${host}:${port}`)
})
})

describe('cliTexts', () => {
it('CLI_UNSUPPORTED_COMMANDS should format correctly', () => {
const commandLine = 'INFO'
const commands = 'AUTH, CONFIG'
const result = cliTexts.CLI_UNSUPPORTED_COMMANDS(commandLine, commands)

expect(result).toBe(
`${`${commandLine} is not supported by the Redis CLI. The list of all unsupported commands: ${commands}`}`,
)
})

it('CLI_ERROR_MESSAGE should render with the correct message', () => {
const message = 'An error occurred'
const result = cliTexts.CLI_ERROR_MESSAGE(message)
const { getByText } = render(<>{result}</>)
expect(getByText(message)).toHaveClass('text-vscode-errorForeground')
})
})
2 changes: 2 additions & 0 deletions src/webviews/src/constants/core/storage.ts
Original file line number Diff line number Diff line change
@@ -30,6 +30,8 @@ enum StorageItem {
OAuthAgreement = 'OAuthAgreement',
cliDatabase = 'cliDatabase',
databaseId = 'databaseId',
openTreeNode = 'openTreeNode',
openTreeDatabase = 'openTreeDatabase',
}

export { StorageItem }
6 changes: 3 additions & 3 deletions src/webviews/src/constants/database/form.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import * as l10n from '@vscode/l10n'

export const ADD_NEW_CA_CERT = 'ADD_NEW_CA_CERT'
export const ADD_NEW_CA_CERT_LABEL = l10n.t('Add new CA certificate')
export const NO_CA_CERT = 'NO_CA_CERT'
export const NO_CA_CERT_LABEL = l10n.t('No CA Certificate')
export const ADD_NEW = 'ADD_NEW'
export const ADD_NEW_LABEL = l10n.t('Add new certificate')
export const ADD_NEW_CA_CERT = { value: 'ADD_NEW_CA_CERT', label: ADD_NEW_CA_CERT_LABEL }
export const NO_CA_CERT = { value: 'NO_CA_CERT', label: NO_CA_CERT_LABEL }
export const ADD_NEW = { value: 'ADD_NEW', label: ADD_NEW_LABEL }
export const NONE = l10n.t('NONE')
export const DEFAULT_HOST = '127.0.0.1'
export const DEFAULT_PORT = '6379'
1 change: 1 addition & 0 deletions src/webviews/src/constants/vscode/vscode.ts
Original file line number Diff line number Diff line change
@@ -27,6 +27,7 @@ export enum VscodeMessageAction {
SaveAppInfo = 'SaveAppInfo',
ShowEula = 'ShowEula',
CloseEula = 'CloseEula',
UpdateDatabaseInList = 'UpdateDatabaseInList',
}

export enum VscodeStateItem {
4 changes: 4 additions & 0 deletions src/webviews/src/index.tsx
Original file line number Diff line number Diff line change
@@ -6,6 +6,7 @@ import {
useSelectedKeyStore,
fetchEditedDatabase,
fetchCerts,
useDatabasesStore,
} from 'uiSrc/store'
import { Config } from 'uiSrc/modules'
import { AppRoutes } from 'uiSrc/Routes'
@@ -53,6 +54,9 @@ document.addEventListener('DOMContentLoaded', () => {
case VscodeMessageAction.RefreshTree:
refreshTreeAction(message)
break
case VscodeMessageAction.UpdateDatabaseInList:
useDatabasesStore.getState().setDatabaseToList(message.data?.database)
break
case VscodeMessageAction.AddDatabase:
addDatabaseAction(message)
break
2 changes: 1 addition & 1 deletion src/webviews/src/interfaces/database/form.ts
Original file line number Diff line number Diff line change
@@ -13,7 +13,7 @@ export interface DbConnectionInfo extends Database {
servername?: string
verifyServerTlsCert?: boolean
caCertificates?: { name: string, id: string }[]
selectedCaCertName: string | typeof ADD_NEW_CA_CERT | typeof NO_CA_CERT
selectedCaCertName: string | typeof ADD_NEW_CA_CERT.value | typeof NO_CA_CERT.value
newCaCertName?: string
newCaCert?: string
username?: string
1 change: 1 addition & 0 deletions src/webviews/src/interfaces/vscode/api.ts
Original file line number Diff line number Diff line change
@@ -34,6 +34,7 @@ export interface SetDatabaseAction {
| VscodeMessageAction.SetDatabase
| VscodeMessageAction.CloseAddDatabase
| VscodeMessageAction.AddDatabase
| VscodeMessageAction.UpdateDatabaseInList
data: {
database: Database
}
2 changes: 1 addition & 1 deletion src/webviews/src/modules/eula/Eula.tsx
Original file line number Diff line number Diff line change
@@ -155,7 +155,7 @@ export const Eula = ({ onSubmitted }: Props) => {
// : TelemetryEvent.SETTINGS_NOTIFICATION_MESSAGES_DISABLED,
// })
// }
updateUserConfigSettingsAction({ agreements: { ...values, encryption: false } }, onSubmitted)
updateUserConfigSettingsAction({ agreements: values }, onSubmitted)
}

const SubmitBtn: ReactElement = (
Original file line number Diff line number Diff line change
@@ -13,7 +13,7 @@ const mockedProps = mock<KeyDetailsHeaderProps>()
const KEY_INPUT_TEST_ID = 'edit-key-input'
const TTL_INPUT_TEST_ID = 'edit-ttl-input'
const KEY_COPY_TEST_ID = 'copy-name-button'
const REFRESH_TEST_ID = 'refresh-key-btn'
const REFRESH_TEST_ID = 'key-refresh-btn'
const supportedKeyTypes = [
KeyTypes.Hash,
KeyTypes.List,
@@ -94,6 +94,6 @@ describe('KeyDetailsHeader', () => {

render(<KeyDetailsHeader {...mockedProps} />)

expect(screen.getByTestId(REFRESH_TEST_ID)?.lastChild).toBeDisabled()
expect(screen.getByTestId(REFRESH_TEST_ID)).toBeDisabled()
})
})
70 changes: 42 additions & 28 deletions src/webviews/src/modules/key-details-header/KeyDetailsHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,17 @@
import React, { ReactElement, ReactNode } from 'react'
import { isUndefined } from 'lodash'
import React, { Fragment, ReactNode } from 'react'
import AutoSizer from 'react-virtualized-auto-sizer'
import { useShallow } from 'zustand/react/shallow'
import * as l10n from '@vscode/l10n'

import {
AllKeyTypes,
HIDE_LAST_REFRESH,
KeyTypes,
} from 'uiSrc/constants'
import { Maybe, RedisString } from 'uiSrc/interfaces'
import { editKeyTTL, refreshKeyInfo, useDatabasesStore, useSelectedKeyStore, editKey } from 'uiSrc/store'
import { TelemetryEvent, formatLongName, getGroupTypeDisplay, sendEventTelemetry } from 'uiSrc/utils'
import { PopoverDelete } from 'uiSrc/components'
import { RefreshBtn } from 'uiSrc/ui'
import { AutoRefresh, PopoverDelete } from 'uiSrc/components'
import { KeyDetailsHeaderFormatter } from './components/key-details-header-formatter'
import { KeyDetailsHeaderName } from './components/key-details-header-name'
import { KeyDetailsHeaderTTL } from './components/key-details-header-ttl'
@@ -35,11 +34,13 @@ const KeyDetailsHeader = ({
onRemoveKey,
onEditKey,
keyType,
Actions,
Actions = Fragment,
}: KeyDetailsHeaderProps) => {
const { data, refreshDisabled, lastRefreshTime } = useSelectedKeyStore(useShallow((state) => ({
const { data, refreshDisabled, loading, refreshing, lastRefreshTime } = useSelectedKeyStore(useShallow((state) => ({
data: state.data,
refreshDisabled: state.refreshDisabled || state.loading,
refreshing: state.refreshing,
loading: state.loading,
lastRefreshTime: state.lastRefreshTime,
})))

@@ -85,24 +86,29 @@ const KeyDetailsHeader = ({
})
}

const RefreshButton = () => (
<RefreshBtn
lastRefreshTime={lastRefreshTime}
disabled={refreshDisabled}
triggerClassName={styles.actionBtn}
onClick={handleRefreshKey}
triggerTestid="refresh-key-btn"
/>
)
const handleEnableAutoRefresh = (enableAutoRefresh: boolean, refreshRate: string) => {
const event = enableAutoRefresh
? TelemetryEvent.TREE_VIEW_KEY_DETAILS_AUTO_REFRESH_ENABLED
: TelemetryEvent.TREE_VIEW_KEY_DETAILS_AUTO_REFRESH_DISABLED
sendEventTelemetry({
event,
eventData: {
length,
databaseId,
keyType: type,
refreshRate: +refreshRate,
},
})
}

const handleChangeAutoRefreshRate = (enableAutoRefresh: boolean, refreshRate: string) => {
if (enableAutoRefresh) {
handleEnableAutoRefresh(enableAutoRefresh, refreshRate)
}
}

return (
<div className={`key-details-header ${styles.container}`} data-testid="key-details-header">
{/* {loading && (
<div>
{l10n.t('loading...')}
</div>
)} */}
{/* {!loading && ( */}
<AutoSizer disableHeight>
{({ width = 0 }) => (
<div style={{ width }}>
@@ -117,12 +123,20 @@ const KeyDetailsHeader = ({
<KeyDetailsHeaderTTL onEditTTL={handleEditTTL} />
<div className="flex ml-auto">
<div className={styles.subtitleActionBtns}>
{isUndefined(Actions) && <RefreshButton />}
{!isUndefined(Actions) && (
<Actions width={width}>
<RefreshButton />
</Actions>
)}
<Actions width={width} key='auto-refresh'>
<AutoRefresh
postfix={type}
disabled={refreshing || refreshDisabled}
loading={refreshDisabled}
lastRefreshTime={lastRefreshTime}
displayText={width > HIDE_LAST_REFRESH}
containerClassName={styles.actionBtn}
onRefresh={handleRefreshKey}
onEnableAutoRefresh={handleEnableAutoRefresh}
onChangeAutoRefreshRate={handleChangeAutoRefreshRate}
testid="key"
/>
</Actions>
{Object.values(KeyTypes).includes(keyType as KeyTypes) && (
<KeyDetailsHeaderFormatter width={width} />
)}
@@ -143,7 +157,7 @@ const KeyDetailsHeader = ({
</div>
)}
</AutoSizer>
{/* )} */}
{(loading || refreshing) && <div className="table-loading" />}
</div>
)
}
Original file line number Diff line number Diff line change
@@ -43,3 +43,11 @@
@apply mr-3 relative;
z-index: 2;
}

.keySpinner {
@apply absolute w-full -ml-4 -bottom-1.5;

span {
@apply w-full #{!important};
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { PropsWithChildren, useState } from 'react'
import React, { PropsWithChildren, useCallback, useState } from 'react'
import cx from 'classnames'
import * as l10n from '@vscode/l10n'

@@ -72,7 +72,7 @@ const HashDetails = (props: Props) => {
})
}

const Actions = ({ children }: PropsWithChildren) => ([
const Actions = useCallback(({ children }: PropsWithChildren) => ([
isExpireFieldsAvailable && (
<Checkbox
checked={showTtl}
@@ -84,7 +84,7 @@ const HashDetails = (props: Props) => {
),
children,
<AddItemsAction key={3} title={l10n.t('Add Fields')} openAddItemPanel={openAddItemPanel} />,
])
]), [])

return (
<div className="fluid flex-column relative">
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { PropsWithChildren, useState } from 'react'
import React, { PropsWithChildren, useCallback, useState } from 'react'
import cx from 'classnames'
import * as l10n from '@vscode/l10n'

@@ -44,11 +44,11 @@ const ListDetails = (props: Props) => {
setIsRemoveItemPanelOpen(true)
}

const Actions = ({ children }: PropsWithChildren) => ([
const Actions = useCallback(({ children }: PropsWithChildren) => ([
children,
<AddItemsAction key={1} title={l10n.t('Add Elements')} openAddItemPanel={openAddItemPanel} />,
<RemoveItemsAction key={2} title={l10n.t('Remove Elements')} openRemoveItemPanel={openRemoveItemPanel} />,
])
]), [])

return (
<div className="fluid flex-column relative">
Original file line number Diff line number Diff line change
@@ -6,8 +6,8 @@ import { sendEventTelemetry, TelemetryEvent, stringToBuffer } from 'uiSrc/utils'
import { KeyDetailsHeader, KeyDetailsHeaderProps } from 'uiSrc/modules'
import { KeyTypes } from 'uiSrc/constants'
import { useDatabasesStore, useSelectedKeyStore } from 'uiSrc/store'
import { Spinner } from 'uiSrc/ui'

import { parseJsonData } from './utils'
import { IJSONData } from './interfaces'
import { RejsonDetails } from './rejson-details'
import { useRejsonStore } from './hooks/useRejsonStore'
@@ -17,8 +17,8 @@ import styles from './styles.module.scss'
export interface Props extends KeyDetailsHeaderProps {}

const RejsonDetailsWrapper = (props: Props) => {
const { data, downloaded, type, path, loading } = useRejsonStore(useShallow((state) => ({
data: state.data.data,
const { updatedData, downloaded, type, path, loading } = useRejsonStore(useShallow((state) => ({
updatedData: state.data.data,
type: state.data.type,
path: state.data.path,
loading: state.loading,
@@ -35,6 +35,8 @@ const RejsonDetailsWrapper = (props: Props) => {

const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set())

const data = parseJsonData(updatedData)

useEffect(() => {
setExpandedRows(new Set())
}, [nameString])
@@ -91,8 +93,6 @@ const RejsonDetailsWrapper = (props: Props) => {
data-testid="json-details"
className={styles.container}
>
{loading && <div className={styles.keySpinner}><Spinner /></div>}

{!isUndefined(data) && (
<RejsonDetails
selectedKey={selectedKey || stringToBuffer('')}
Original file line number Diff line number Diff line change
@@ -83,6 +83,7 @@ export const EditEntireItemAction = (props: Props) => {
className={styles.declineBtn}
onClick={onCancel}
aria-label="Cancel add"
data-testid="cancel-edit-btn"
>
<VscChromeClose />
</VSCodeButton>
Original file line number Diff line number Diff line change
@@ -22,6 +22,7 @@ import {
RejsonState,
RejsonDataState,
} from './interface'
import { parseJsonData } from '../utils'

const JSON_LENGTH_TO_FORCE_RETRIEVE = 200
// eslint-disable-next-line import/no-mutable-exports
@@ -222,7 +223,10 @@ export const fetchVisualisationResults = async (
)

if (isStatusSuccessful(status)) {
return data
return {
...data,
data: parseJsonData(data?.data),
}
}

throw new Error(data.toString())
Original file line number Diff line number Diff line change
@@ -65,7 +65,7 @@ export interface DynamicTypesProps {

interface JSONCommonProps {
keyName: string | number
value: IJSONValue
value: string | number | boolean | bigint
cardinality?: number
selectedKey: RedisString
path?: string
Original file line number Diff line number Diff line change
@@ -85,7 +85,11 @@ export const RejsonObject = React.memo((props: JSONObjectProps) => {
const data = await handleFetchVisualisationResults(selectedKey, path, true)

setEditEntireObject(true)
setValueOfEntireObject(typeof data.data === 'object' ? JSON.stringify(data.data, undefined, 4) : data.data)
setValueOfEntireObject(typeof data.data === 'object' ? JSON.stringify(data.data, (_key, value) => (
typeof value === 'bigint'
? value.toString()
: value
), 4) : data.data)
}

const onClickExpandCollapse = (path: string) => {
Original file line number Diff line number Diff line change
@@ -98,4 +98,44 @@ describe('JSONScalar', () => {

expect(handleEdit).not.toBeCalled()
})

it('should render BigInt value when root', () => {
render(<RejsonScalar
{...instance(mockedProps)}
isRoot
value={BigInt('1188950299261208742')}
/>)

expect(screen.getByText('1188950299261208742')).toBeInTheDocument()
})

it('should render BigInt value when not root', () => {
render(<RejsonScalar
{...instance(mockedProps)}
isRoot={false}
value={BigInt('1188950299261208742')}
/>)

expect(screen.getByTestId('json-scalar-value')).toHaveTextContent('1188950299261208742')
})

it('should render regular number without n suffix', () => {
render(<RejsonScalar
{...instance(mockedProps)}
isRoot
value={123}
/>)

expect(screen.getByText('123')).toBeInTheDocument()
})

it('should render string value with quotes', () => {
render(<RejsonScalar
{...instance(mockedProps)}
isRoot
value="test"
/>)

expect(screen.getByText('"test"')).toBeInTheDocument()
})
})
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
import React, { useEffect, useState } from 'react'
import cx from 'classnames'
import { GoAlert } from 'react-icons/go'
import { isNull, isString } from 'lodash'

import { InlineEditor, PopoverDelete, FieldMessage } from 'uiSrc/components'
import { TelemetryEvent, bufferToString, createDeleteFieldHeader, createDeleteFieldMessage, sendEventTelemetry } from 'uiSrc/utils'
import { Nullable } from 'uiSrc/interfaces'
import { useKeysInContext } from 'uiSrc/modules/keys-tree/hooks/useKeys'

import { JSONScalarProps } from '../interfaces'
import { generatePath, getClassNameByValue, isValidJSON } from '../utils'
import { generatePath, getClassNameByValue, isValidJSON, stringifyScalarValue } from '../utils'
import { setReJSONDataAction } from '../hooks/useRejsonStore'
import { JSONErrors } from '../constants'

@@ -36,7 +35,7 @@ export const RejsonScalar = (props: JSONScalarProps) => {
const databaseId = useKeysInContext((state) => state.databaseId)

useEffect(() => {
setChangedValue(isString(value) ? `"${value}"` : isNull(value) ? 'null' : value)
setChangedValue(stringifyScalarValue(value))
}, [value])

const onDeclineChanges = () => {
Original file line number Diff line number Diff line change
@@ -149,14 +149,6 @@
}
}

.keySpinner {
@apply absolute w-full;

span {
@apply w-full #{!important};
}
}

.fullWidthTextArea {
@apply h-[150px] w-full pb-[30px] max-w-none scroll-pb-5;
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { generatePath, getBrackets, isRealArray, isRealObject, isScalar, isValidKey, wrapPath } from './utils'
import { generatePath, getBrackets, isRealArray, isRealObject, isScalar, isValidKey, parseJsonData, parseValue, stringifyScalarValue, wrapPath } from './utils'
import { ObjectTypes } from '../interfaces'

describe('JSONUtils', () => {
@@ -79,4 +79,145 @@ describe('JSONUtils', () => {
expect(isValidKey('"')).toBeFalsy()
})
})

describe('JSON Parsing Utils', () => {
const bigintAsString = '1188950299261208742'
const scientificNotation = 1.2345678901234568e+29

describe('parseValue', () => {
it('should handle non-string values', () => {
expect(parseValue(123)).toBe(123)
expect(parseValue(null)).toBe(null)
expect(parseValue(undefined)).toBe(undefined)
})

it('should parse typed integer values', () => {
const result = parseValue(bigintAsString, 'integer')
expect(typeof result).toBe('bigint')
expect(result.toString()).toBe(bigintAsString)
})

it('should parse regular numbers as numbers, not bigints', () => {
const result = parseValue('42', 'integer')
expect(typeof result).toBe('number')
expect(result).toBe(42)
})

it('should preserve string values in JSON objects', () => {
const input = '{"a":"111"}'
const result = parseValue(input)
expect(result.a).toBe('111')
expect(typeof result.a).toBe('string')
})

it('should handle mixed string and number values in JSON objects', () => {
const input = '{"stringVal":"111","numberVal":111}'
const result = parseValue(input)
expect(result.stringVal).toBe('111')
expect(typeof result.stringVal).toBe('string')
expect(result.numberVal).toBe(111)
expect(typeof result.numberVal).toBe('number')
})

it('should handle string type with quotes', () => {
expect(parseValue('"test"', 'string')).toBe('test')
expect(parseValue('test', 'string')).toBe('test')
})

it('should parse boolean values', () => {
expect(parseValue('true', 'boolean')).toBe(true)
expect(parseValue('false', 'boolean')).toBe(false)
})

it('should parse null values', () => {
expect(parseValue('null', 'null')).toBe(null)
})

it('should parse JSON objects without type', () => {
const input = `{"value": ${bigintAsString}, "text": "test"}`
const result = parseValue(input)
expect(typeof result.value).toBe('bigint')
expect(result.value.toString()).toBe(bigintAsString)
expect(result.text).toBe('test')
})

it('should parse JSON arrays without type', () => {
const input = `[${bigintAsString}, "test"]`
const result = parseValue(input)
expect(typeof result[0]).toBe('bigint')
expect(result[0].toString()).toBe(bigintAsString)
expect(result[1]).toBe('test')
})

it('should handle extremely large integers and maintain scientific notation', () => {
const resultFromString = parseValue(`'${scientificNotation}'`, 'integer')
expect(resultFromString).toBe(`'${scientificNotation}'`)

const resultFromInt = parseValue(scientificNotation, 'integer')
expect(resultFromInt).toBe(scientificNotation)

// Also test parsing as part of JSON
const jsonWithLargeInt = `{"value": ${scientificNotation}}`
const parsedJson = parseValue(jsonWithLargeInt)
expect(parsedJson.value).toBe(scientificNotation)
})
})

describe('parseJsonData', () => {
it('should handle null or undefined data', () => {
expect(parseJsonData(null)).toBe(null)
expect(parseJsonData(undefined)).toBe(undefined)
})

it('should parse array of typed values', () => {
const input = [
{ type: 'string', value: '"John"' },
{ type: 'integer', value: bigintAsString },
]
const result = parseJsonData(input)

expect(result[0].value).toBe('John')
expect(typeof result[1].value).toBe('bigint')
expect(result[1].value.toString()).toBe(bigintAsString)
})

it('should preserve non-typed array items', () => {
const input = [
{ value: '"John"' },
{ someOtherProp: 'test' },
]
const result = parseJsonData(input)

expect(result[0].value).toBe('"John"')
expect(result[1].someOtherProp).toBe('test')
})
})
})

describe('stringifyScalarValue', () => {
it('should handle bigint values', () => {
const bigIntValue = BigInt('9007199254740991')
expect(stringifyScalarValue(bigIntValue)).toBe('9007199254740991')
})

it('should wrap string values in quotes', () => {
expect(stringifyScalarValue('hello')).toBe('"hello"')
expect(stringifyScalarValue('')).toBe('""')
})

it('should convert null to "null" string', () => {
expect(stringifyScalarValue(null as any)).toBe('null')
})

it('should convert numbers to string representation', () => {
expect(stringifyScalarValue(42)).toBe('42')
expect(stringifyScalarValue(-123.456)).toBe('-123.456')
expect(stringifyScalarValue(0)).toBe('0')
})

it('should convert boolean values to string representation', () => {
expect(stringifyScalarValue(true)).toBe('true')
expect(stringifyScalarValue(false)).toBe('false')
})
})
})
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { isArray } from 'lodash'
import { isArray, isNull, isString } from 'lodash'
import JSONBigInt from 'json-bigint'
import { JSONScalarValue, ObjectTypes } from '../interfaces'
import styles from '../styles.module.scss'

@@ -11,7 +12,7 @@ enum ClassNames {
}

export function isScalar(x: JSONScalarValue) {
return ['string', 'number', 'boolean'].indexOf(typeof x) !== -1 || x === null
return ['string', 'number', 'boolean', 'bigint'].indexOf(typeof x) !== -1 || x === null
}

export const isValidJSON = (value: string): boolean => {
@@ -61,3 +62,112 @@ export const getBrackets = (type: string, position: 'start' | 'end' = 'start') =
}

export const isValidKey = (key: string): boolean => /^"([^"\\]|\\.)*"$/.test(key)

const JSONParser = JSONBigInt({
useNativeBigInt: true,
strict: false,
alwaysParseAsBig: false,
protoAction: 'preserve',
constructorAction: 'preserve',
})

const safeJSONParse = (value: string) => {
// Pre-process the string to handle scientific notation
const preprocessed = value.replace(/-?\d+\.?\d*e[+-]?\d+/gi, (match) =>
// Wrap scientific notation numbers in quotes to prevent BigInt conversion
`"${match}"`)

return JSONParser.parse(preprocessed, (_key: string, value: any) => {
// Convert quoted scientific notation back to numbers
if (typeof value === 'string' && /^-?\d+\.?\d*e[+-]?\d+$/i.test(value)) {
return Number(value)
}
return value
})
}

export const parseValue = (value: any, type?: string): any => {
try {
if (typeof value !== 'string' || !value) {
return value
}

if (type) {
switch (type) {
case 'integer': {
const num = BigInt(value)
return num > Number.MAX_SAFE_INTEGER ? num : Number(value)
}
case 'number':
return Number(value)
case 'boolean':
return value === 'true'
case 'null':
return null
case 'string':
if (value.startsWith('"') && value.endsWith('"')) {
return value.slice(1, -1)
}
return value
default:
return value
}
}

const parsed = safeJSONParse(value)

if (typeof parsed === 'object' && parsed !== null) {
if (Array.isArray(parsed)) {
return parsed.map((val) => parseValue(val))
}
const result: { [key: string]: any } = {}
Object.entries(parsed).forEach(([key, val]) => {
// This prevents double-parsing of JSON string values.
if (typeof val === 'string') {
result[key] = val
} else {
result[key] = parseValue(val)
}
})
return result
}
return parsed
} catch (e) {
try {
return JSON.parse(value)
} catch (error) {
return value
}
}
}

export const parseJsonData = (data: any) => {
if (!data) {
return data
}
try {
if (data && Array.isArray(data)) {
return data.map((item: { type?: string; value?: any }) => ({
...item,
value: item.type && item.value ? parseValue(item.value, item.type) : item.value,
}))
}

return parseValue(data)
} catch (e) {
return data
}
}

export const stringifyScalarValue = (value: string | number | boolean | bigint): string => {
if (typeof value === 'bigint') {
return value.toString()
}
if (isString(value)) {
return `"${value}"`
}
if (isNull(value)) {
return 'null'
}
return String(value)
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { PropsWithChildren, useState } from 'react'
import React, { PropsWithChildren, useCallback, useState } from 'react'
import cx from 'classnames'
import * as l10n from '@vscode/l10n'

@@ -54,10 +54,10 @@ export const SetDetails = (props: Props) => {
addSetMembersAction(data, () => onSuccessAdded(data.members))
}

const Actions = ({ children }: PropsWithChildren) => ([
const Actions = useCallback(({ children }: PropsWithChildren) => ([
children,
<AddItemsAction key={1} title={l10n.t('Add Members')} openAddItemPanel={openAddItemPanel} />,
])
]), [])

return (
<div className="fluid flex-column relative">
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { PropsWithChildren, useEffect, useState } from 'react'
import React, { PropsWithChildren, useCallback, useEffect, useState } from 'react'
import { useShallow } from 'zustand/react/shallow'
import * as l10n from '@vscode/l10n'

@@ -92,7 +92,7 @@ const StringDetails = (props: Props) => {
})
}

const Actions = ({ children }: PropsWithChildren) => ([
const Actions = useCallback(({ children }: PropsWithChildren) => ([
children,
<EditItemAction
key={1}
@@ -103,7 +103,7 @@ const StringDetails = (props: Props) => {
setEditItem(!editItem)
}}
/>,
])
]), [])

return (
<div className="fluid flex-column relative">
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { ReactNode, useState } from 'react'
import React, { ReactNode, useCallback, useState } from 'react'
import cx from 'classnames'
import * as l10n from '@vscode/l10n'

@@ -52,10 +52,10 @@ const ZSetDetails = (props: Props) => {
updateZSetMembersAction(data, true, () => onSuccessAdded(data.members))
}

const Actions = ({ children }: { children: ReactNode }) => ([
const Actions = useCallback(({ children }: { children: ReactNode }) => ([
children,
<AddItemsAction key={1} title={l10n.t('Add Members')} openAddItemPanel={openAddItemPanel} />,
])
]), [])

return (
<div className="fluid flex-column relative">
14 changes: 7 additions & 7 deletions src/webviews/src/modules/keys-tree/KeysTree.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import React, { useEffect, useState } from 'react'
import * as l10n from '@vscode/l10n'
import cx from 'classnames'
import { isUndefined } from 'lodash'
import { isString, isUndefined } from 'lodash'

import { KeyInfo, Nullable, RedisString } from 'uiSrc/interfaces'
import { AllKeyTypes, VscodeMessageAction } from 'uiSrc/constants'
@@ -36,6 +35,7 @@ export const KeysTree = ({ database }: Props) => {

const keysState = useKeysInContext((state) => state.data)
const loading = useKeysInContext((state) => state.loading)
const dbIndex = useKeysInContext((state) => state.databaseIndex ?? 0)

const keysApi = useKeysApi()
const contextApi = useContextApi()
@@ -58,8 +58,8 @@ export const KeysTree = ({ database }: Props) => {

// open all parents for selected key
const openSelectedKey = (selectedKeyName: Nullable<string> = '') => {
if (selectedKeyName) {
const parts = selectedKeyName.split(delimiter)
if (selectedKeyName && isString(selectedKeyName)) {
const parts = selectedKeyName?.split(delimiter)
const parents = parts.map((_, index) => parts.slice(0, index + 1).join(delimiter) + delimiter)

// remove key name from parents
@@ -111,11 +111,11 @@ export const KeysTree = ({ database }: Props) => {
if (isUndefined(type)) {
return
}
fetchKeyInfo({ key: name, databaseId: database.id }, false, () => {
fetchKeyInfo({ key: name, databaseId: database.id, dbIndex }, false, () => {
vscodeApi.postMessage({
action: VscodeMessageAction.SelectKey,
data: {
database,
database: { ...database, db: dbIndex },
keyInfo: { key: name, keyString, keyType: type, displayedKeyType: getGroupTypeDisplay(type) },
},
})
@@ -152,7 +152,7 @@ export const KeysTree = ({ database }: Props) => {

return (
<div className="pl-8">
<NoKeysMessage total={keysState.total} database={database} />
<NoKeysMessage total={keysState.total} database={database} dbIndex={dbIndex} />
</div>
)
}
Original file line number Diff line number Diff line change
@@ -30,8 +30,7 @@ const deleteKeyFromTreeMock = vi.fn();
(vi.spyOn(useKeys, 'useKeysApi') as Mock).mockImplementation(() => ({
fetchPatternKeysAction: fnMock,
setDatabaseId: fnMock,
addKeyIntoTree: addKeyIntoTreeMock,
deleteKeyFromTree: deleteKeyFromTreeMock,
setDatabaseIndex: fnMock,
}))
const setKeysTreeSortMock = vi.fn()
const resetKeysTreeMock = vi.fn();
@@ -51,12 +50,21 @@ describe('DatabaseWrapper', () => {
fireEvent.click(queryByTestId(`database-${mockDatabase.id}`)!)
await waitForStack()

fireEvent.click(queryByTestId('refresh-keys')!)
fireEvent.click(queryByTestId('refresh-keys-refresh-btn')!)
await waitForStack()

expect(useKeys.useKeysApi().fetchPatternKeysAction).toBeCalled()
})

it('should render logical databases', async () => {
const { queryByTestId } = render(<DatabaseWrapper {...mockedProps} />)

fireEvent.click(queryByTestId(`database-${mockDatabase.id}`)!)
await waitForStack()

expect(queryByTestId(`logical-database-${mockDatabase.id}-0`)!).toBeInTheDocument()
})

describe('selectedKeyAction', () => {
const setSelectedKeyActionMock = vi.fn()
const setSelectedKeyMock = vi.fn()
@@ -83,15 +91,6 @@ describe('DatabaseWrapper', () => {
vi.restoreAllMocks()
})

it('should call deleteKeyFromTree and setSelectedKeyAction action after if selected key action is Removed', async () => {
render(<DatabaseWrapper {...mockedProps} database={{ id: constants.DATABASE_ID } as Database} />)

await waitForStack()

expect(setSelectedKeyActionMock).toBeCalledWith(null)
expect(deleteKeyFromTreeMock).toBeCalledWith(constants.KEY_NAME_1)
})

it('should not call any mocks if database is not equal', async () => {
render(<DatabaseWrapper {...mockedProps} database={{ id: '123123' } as Database} />)

@@ -111,28 +110,5 @@ describe('DatabaseWrapper', () => {
expect(deleteKeyFromTreeMock).not.toBeCalled()
expect(addKeyIntoTreeMock).not.toBeCalled()
})

it('should call addKeyIntoTree action after if selected key action is Added', async () => {
const spySelectedKey = vi.spyOn(useSelectedKeyStore, 'useSelectedKeyStore') as Mock

const setSelectedKeyActionMock = vi.fn()
const setSelectedKeyMock = vi.fn()

spySelectedKey.mockImplementation(() => ({
selectedKeyAction: {
...selectedKeyAction,
type: SelectedKeyActionType.Added,
},
setSelectedKeyAction: setSelectedKeyActionMock,
setSelectedKey: setSelectedKeyMock,
}))

render(<DatabaseWrapper {...mockedProps} database={{ id: constants.DATABASE_ID } as Database} />)

expect(setSelectedKeyMock).toBeCalledWith({ name: constants.KEY_NAME_1 })
expect(setSelectedKeyActionMock).toBeCalledWith(null)
expect(deleteKeyFromTreeMock).not.toBeCalled()
expect(addKeyIntoTreeMock).not.toBeCalled()
})
})
})
Original file line number Diff line number Diff line change
@@ -1,77 +1,53 @@
import React, { useEffect, useState } from 'react'
import { VSCodeButton } from '@vscode/webview-ui-toolkit/react'
import cx from 'classnames'
import { VscChevronRight, VscChevronDown, VscTerminal, VscEdit } from 'react-icons/vsc'
import { VscEdit } from 'react-icons/vsc'
import { isUndefined, toNumber } from 'lodash'
import * as l10n from '@vscode/l10n'
import { useShallow } from 'zustand/react/shallow'
import { set } from 'lodash'
import { VSCodeButton } from '@vscode/webview-ui-toolkit/react'

import { vscodeApi } from 'uiSrc/services'
import { POPOVER_WINDOW_BORDER_WIDTH, SelectedKeyActionType, VscodeMessageAction } from 'uiSrc/constants'
import {
TelemetryEvent,
formatLongName,
getDbIndex,
getRedisModulesSummary,
sendEventTelemetry,
} from 'uiSrc/utils'
import { Database, checkConnectToDatabase, deleteDatabases, useSelectedKeyStore } from 'uiSrc/store'
import DatabaseOfflineIconSvg from 'uiSrc/assets/database/database_icon_offline.svg?react'
import DatabaseActiveIconSvg from 'uiSrc/assets/database/database_icon_active.svg?react'
import { ContextStoreProvider, Database, DatabaseOverview, checkConnectToDatabase, deleteDatabases } from 'uiSrc/store'
import { Chevron, DatabaseIcon, Tooltip } from 'uiSrc/ui'
import { PopoverDelete } from 'uiSrc/components'
import { RefreshBtn, Tooltip } from 'uiSrc/ui'
import { useKeysApi, useKeysInContext } from '../../hooks/useKeys'

import { POPOVER_WINDOW_BORDER_WIDTH, StorageItem, VscodeMessageAction } from 'uiSrc/constants'
import { sessionStorageService, vscodeApi } from 'uiSrc/services'
import { Maybe } from 'uiSrc/interfaces'

import { LogicalDatabaseWrapper } from '../logical-database-wrapper'
import { KeysTreeHeader } from '../keys-tree-header'
import { KeysStoreProvider } from '../../hooks/useKeys'
import { KeysTree } from '../../KeysTree'
import styles from './styles.module.scss'

export interface Props {
database: Database
children: React.ReactNode
}

export const DatabaseWrapper = ({ children, database }: Props) => {
export const DatabaseWrapper = React.memo(({ database }: Props) => {
const { id, name } = database

const lastRefreshTime = useKeysInContext((state) => state.data.lastRefreshTime)
const { selectedKeyAction, setSelectedKeyAction, setSelectedKey } = useSelectedKeyStore(useShallow((state) => ({
selectedKeyAction: state.action,
setSelectedKeyAction: state.setSelectedKeyAction,
setSelectedKey: state.processSelectedKeySuccess,
})))

const [showTree, setShowTree] = useState<boolean>(false)

const keysApi = useKeysApi()
const [totalKeysPerDb, setTotalKeysPerDb] = useState<Maybe<Record<string, number>>>(undefined)

useEffect(() => {
const { type, keyInfo, database: databaseAction } = selectedKeyAction || {}
const { key, keyType, newKey } = keyInfo || {}
const { id: databaseId } = databaseAction || {}
const showTreeInit = !!sessionStorageService.get(`${StorageItem.openTreeDatabase + id}`)

if (!type || databaseId !== database.id) {
return
}

switch (type) {
case SelectedKeyActionType.Added:
keysApi.addKeyIntoTree(key!, keyType!)
setSelectedKey({ name: key! })
break
case SelectedKeyActionType.Removed:
keysApi.deleteKeyFromTree(key!)
break
case SelectedKeyActionType.Renamed:
keysApi.editKeyName(key!, newKey!)
break
default:
break
if (showTreeInit) {
checkConnectToDatabase(id, connectToInstance)
}
setSelectedKeyAction(null)
}, [selectedKeyAction])
}, [])

const handleCheckConnectToDatabase = ({ id, provider, modules }: Database) => {
const newShowTree = !showTree
sessionStorageService.set(`${StorageItem.openTreeDatabase + id}`, newShowTree)

if (showTree) {
setShowTree(false)
setShowTree(newShowTree)
return
}
const modulesSummary = getRedisModulesSummary(modules)
@@ -87,16 +63,12 @@ export const DatabaseWrapper = ({ children, database }: Props) => {
checkConnectToDatabase(id, connectToInstance)
}

const connectToInstance = (database: Database) => {
keysApi.setDatabaseId(database.id)

// todo: fix for cli first open
set(window, 'ri.database', database)
const connectToInstance = (database: Database, overview: DatabaseOverview) => {
// TODO: fix for cli first open
// TODO: remove after tests
// set(window, 'ri.database', database)
setShowTree(!showTree)
}

const openCliClickHandle = () => {
vscodeApi.postMessage({ action: VscodeMessageAction.AddCli, data: { database } })
setTotalKeysPerDb(overview?.totalKeysPerDb)
}

const editHandle = () => {
@@ -123,9 +95,24 @@ export const DatabaseWrapper = ({ children, database }: Props) => {
})
}

const refreshHandle = () => {
keysApi.fetchPatternKeysAction()
}
const LogicalDatabase = (
{ database, open, dbTotal }:
{ database: Database, open?: boolean, dbTotal?: number },
) => (
<ContextStoreProvider>
<KeysStoreProvider>
<LogicalDatabaseWrapper database={database}>
<KeysTreeHeader
database={database}
open={open}
dbTotal={dbTotal}
>
<KeysTree database={database} />
</KeysTreeHeader>
</LogicalDatabaseWrapper>
</KeysStoreProvider>
</ContextStoreProvider>
)

return (
<div className={cx('flex w-full flex-col')}>
@@ -137,10 +124,8 @@ export const DatabaseWrapper = ({ children, database }: Props) => {
className={styles.databaseNameWrapper}
data-testid={`database-${id}`}
>
{showTree && (<VscChevronDown className={cx(styles.icon, styles.iconNested)} />)}
{showTree && (<DatabaseActiveIconSvg className={styles.icon} />)}
{!showTree && (<VscChevronRight className={cx(styles.icon, styles.iconNested)} />)}
{!showTree && (<DatabaseOfflineIconSvg className={styles.icon} />)}
{<Chevron open={showTree} />}
{<DatabaseIcon open={showTree} />}
<Tooltip
content={formatLongName(name, 100, 20)}
position="bottom center"
@@ -149,19 +134,10 @@ export const DatabaseWrapper = ({ children, database }: Props) => {
>
<div className={styles.databaseName}>
<div className="truncate">{name}</div>
<div>{getDbIndex(database.db)}</div>
</div>
</Tooltip>
</div>
<div className="flex pr-3.5">
{showTree && (
<RefreshBtn
lastRefreshTime={lastRefreshTime}
position="left center"
onClick={refreshHandle}
triggerTestid="refresh-keys"
/>
)}
<VSCodeButton
appearance="icon"
onClick={editHandle}
@@ -172,7 +148,7 @@ export const DatabaseWrapper = ({ children, database }: Props) => {
</VSCodeButton>
<PopoverDelete
header={formatLongName(name, 50, 10, '...')}
text={l10n.t('will be deleted from Redis for VS Code.')}
text={l10n.t('will be removed from Redis for VS Code.')}
item={id}
position="bottom right"
maxWidth={window.innerWidth - POPOVER_WINDOW_BORDER_WIDTH}
@@ -182,14 +158,28 @@ export const DatabaseWrapper = ({ children, database }: Props) => {
handleButtonClick={() => clickDeleteDatabaseHandle()}
testid={`delete-database-${id}`}
/>
{showTree && (
<VSCodeButton appearance="icon" onClick={openCliClickHandle} data-testid="terminal-button">
<VscTerminal />
</VSCodeButton>
)}
</div>
</div>
{showTree && children}
{showTree && (<>
{!isUndefined(totalKeysPerDb) && Object.keys(totalKeysPerDb).map((databaseIndex) => (
<LogicalDatabase
key={id + databaseIndex}
open={Object.keys(totalKeysPerDb)?.length === 1}
dbTotal={totalKeysPerDb?.[databaseIndex]}
database={{
...database,
db: toNumber(databaseIndex.replace('db', '')),
}}
/>
))}
{isUndefined(totalKeysPerDb) && (
<LogicalDatabase
key={id}
database={{ ...database, db: 0 }}
open={true}
/>
)}
</>)}
</div>
)
}
})
Original file line number Diff line number Diff line change
@@ -14,8 +14,11 @@ const mockedProps: Props = {
database: mockDatabase,
total: 1,
scanned: 1,
dbIndex: 0,
showTree: true,
resultsLength: 1,
loading: false,
toggleShowTree: () => {},
}

vi.spyOn(utils, 'sendEventTelemetry')
@@ -76,12 +79,12 @@ describe('KeysSummary', () => {
})

it('should call sendEventTelemetry and postMessage actions after click on Add Key icon', async () => {
const { queryByTestId } = render(<KeysSummary {...mockedProps} />)
const { queryByTestId } = render(<KeysSummary {...mockedProps} dbIndex={mockDatabase.db!} />)

fireEvent.click(queryByTestId('add-key-button')!)

expect(vscodeApi.postMessage).toBeCalledWith({
action: VscodeMessageAction.AddKey, data: { database: mockDatabase },
action: VscodeMessageAction.AddKey, data: { database: { ...mockDatabase } },
})

expect(utils.sendEventTelemetry).toBeCalledWith({
Original file line number Diff line number Diff line change
@@ -3,16 +3,19 @@ import * as l10n from '@vscode/l10n'
import cx from 'classnames'
import { VSCodeButton } from '@vscode/webview-ui-toolkit/react'
import { BiSortDown, BiSortUp } from 'react-icons/bi'
import { VscAdd } from 'react-icons/vsc'
import { VscAdd, VscDatabase, VscTerminal } from 'react-icons/vsc'
import { isUndefined } from 'lodash'

import { TelemetryEvent, nullableNumberWithSpaces, numberWithSpaces, sendEventTelemetry } from 'uiSrc/utils'
import { vscodeApi } from 'uiSrc/services'
import { SortOrder, VscodeMessageAction } from 'uiSrc/constants'
import { Database, useContextApi, useContextInContext } from 'uiSrc/store'
import { Nullable } from 'uiSrc/interfaces'
import { Tooltip } from 'uiSrc/ui'
import { checkDatabaseIndexAction, Database, useContextApi, useContextInContext } from 'uiSrc/store'
import { Maybe, Nullable } from 'uiSrc/interfaces'
import { Chevron, Tooltip } from 'uiSrc/ui'
import { AutoRefresh } from 'uiSrc/components'

import { KeyTreeFilter } from '../keys-tree-filter'
import { useKeysApi, useKeysInContext } from '../../hooks/useKeys'
import styles from './styles.module.scss'

export interface Props {
@@ -21,13 +24,19 @@ export interface Props {
resultsLength: number
loading: boolean
total: Nullable<number>
showTree: boolean
dbIndex: Maybe<number>
toggleShowTree: (value?: boolean) => void
}

export const KeysSummary = (props: Props) => {
const { loading, total, scanned, resultsLength, database } = props
const { loading, total, scanned, resultsLength, database, showTree, dbIndex, toggleShowTree } = props
const sorting = useContextInContext((state) => state.dbConfig.treeViewSort)
const lastRefreshTime = useKeysInContext((state) => state.data.lastRefreshTime)

const isMultiDbIndex = !isUndefined(dbIndex)
const contextApi = useContextApi()
const keysApi = useKeysApi()

const isSortingASC = sorting === SortOrder.ASC

@@ -36,7 +45,10 @@ export const KeysSummary = (props: Props) => {
event: TelemetryEvent.TREE_VIEW_KEY_ADD_BUTTON_CLICKED,
eventData: { databaseId: database.id },
})
vscodeApi.postMessage({ action: VscodeMessageAction.AddKey, data: { database } })
vscodeApi.postMessage({
action: VscodeMessageAction.AddKey,
data: { database: { ...database, db: dbIndex } },
})
}

const changeSortHandle = () => {
@@ -53,27 +65,100 @@ export const KeysSummary = (props: Props) => {
})
}

return (
<div className="flex flex-row justify-between pl-5 ">
<div className={styles.content} data-testid="keys-summary">
const handleToggleShowTree = () => {
if (!isMultiDbIndex) {
return
}
if (!showTree) {
checkDatabaseIndexAction(database.id, dbIndex, () => toggleShowTree())
return
}
toggleShowTree()
}

const openCliClickHandle = () => {
vscodeApi.postMessage({
action: VscodeMessageAction.AddCli,
data: { database: { ...database, db: dbIndex } },
})
}

const refreshHandle = () => {
keysApi.fetchPatternKeysAction()
}

const handleEnableAutoRefresh = (enableAutoRefresh: boolean, refreshRate: string) => {
const event = enableAutoRefresh
? TelemetryEvent.TREE_VIEW_KEY_LIST_AUTO_REFRESH_ENABLED
: TelemetryEvent.TREE_VIEW_KEY_LIST_AUTO_REFRESH_DISABLED
sendEventTelemetry({
event,
eventData: {
databaseId: database?.id,
refreshRate: +refreshRate,
},
})
}

const handleChangeAutoRefreshRate = (enableAutoRefresh: boolean, refreshRate: string) => {
if (enableAutoRefresh) {
handleEnableAutoRefresh(enableAutoRefresh, refreshRate)
}
}

const DbIndex = () => {
if (!isMultiDbIndex) return null
return (
<>
<VscDatabase className="sidebar-icon sidebar-icon-nested" />
<span className="px-1">{dbIndex}</span>
</>
)
}

const Summary = () => {
if (!showTree) {
return (
<span>
(
<span data-testid="keys-number-of-results">{numberWithSpaces(resultsLength)}</span>
{' / '}
{'('}
<span data-testid="keys-total">{nullableNumberWithSpaces(total)}</span>
)
<span
className={cx([styles.loading, { [styles.loadingShow]: loading }])}
/>
{')'}
</span>
)
}

return (
<span className="truncate">
{'('}
<span data-testid="keys-number-of-results">{numberWithSpaces(resultsLength)}</span>
{' / '}
<span data-testid="keys-total">{nullableNumberWithSpaces(total)}</span>
{')'}
<span
className={cx(styles.loading, { [styles.loadingShow]: loading })}
/>
</span>
)
}

return (
<div className={cx(styles.container, 'group')}>
<div
className={cx(styles.content, { 'cursor-pointer pl-0': isMultiDbIndex })}
onClick={handleToggleShowTree}
data-testid="keys-summary"
>
{<Chevron open={showTree} hidden={!isMultiDbIndex} />}
{<DbIndex />}
{<Summary />}
</div>
{/* {loading && !total && !isNull(total) && (
<div data-testid="scanning-text">
{l10n.t('Scanning...')}
</div>
)} */}

<div className="flex pr-3.5">
<div className={cx('hidden', 'pr-3.5', 'group-hover:!flex', { '!flex': showTree })}>
<Tooltip
keepTooltipInside={false}
content={l10n.t('Sort by key names displayed')}
@@ -91,6 +176,19 @@ export const KeysSummary = (props: Props) => {
<VSCodeButton appearance="icon" onClick={addKeyClickHandle} data-testid="add-key-button">
<VscAdd />
</VSCodeButton>
<AutoRefresh
displayText={false}
loading={loading}
lastRefreshTime={lastRefreshTime}
onRefresh={refreshHandle}
onEnableAutoRefresh={handleEnableAutoRefresh}
onChangeAutoRefreshRate={handleChangeAutoRefreshRate}
testid="refresh-keys"
postfix="logical-database"
/>
<VSCodeButton appearance="icon" onClick={openCliClickHandle} data-testid="terminal-button">
<VscTerminal />
</VSCodeButton>
</div>
</div>
)
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
.container {
@apply h-[22px] flex flex-row justify-between pl-6;
&:hover {
background-color: var(--vscode-merge-commonHeaderBackground);
}
}

.content {
@apply flex-grow-0 text-vscode-foreground opacity-80 pl-3;
@apply flex flex-row items-center flex-grow text-vscode-foreground opacity-80 pl-1 truncate;
}
Original file line number Diff line number Diff line change
@@ -1,21 +1,29 @@
import React from 'react'

import { render } from 'testSrc/helpers'
import { KeysTreeHeader } from './KeysTreeHeader'
import { render, constants } from 'testSrc/helpers'
import { KeysTreeHeader, Props } from './KeysTreeHeader'
import * as useKeys from '../../hooks/useKeys'

const useKeysInContextMock = vi.spyOn(useKeys, 'useKeysInContext')

const mockDatabase = constants.DATABASE
const mockedProps: Props = {
database: mockDatabase,
dbTotal: 1,
open: true,
children: <div/>,
}

describe('KeysTreeHeaders', () => {
it('should render', () => {
expect(render(<KeysTreeHeader />)).toBeTruthy()
expect(render(<KeysTreeHeader {...mockedProps} />)).toBeTruthy()
})

it.skip('should hide Scan more button when totalItemsCount < scanned', () => {
const initialState = { scanned: 2, total: 1 }
useKeysInContextMock.mockImplementation(() => initialState)

const { queryByTestId } = render(<KeysTreeHeader />)
const { queryByTestId } = render(<KeysTreeHeader {...mockedProps} />)

expect(queryByTestId('scan-more')).not.toBeInTheDocument()
})
@@ -25,7 +33,7 @@ describe('KeysTreeHeaders', () => {

useKeysInContextMock.mockImplementation(() => initialState)

const { queryByTestId } = render(<KeysTreeHeader />)
const { queryByTestId } = render(<KeysTreeHeader {...mockedProps} />)

expect(queryByTestId('scan-more')).toBeInTheDocument()
})
@@ -35,7 +43,7 @@ describe('KeysTreeHeaders', () => {

useKeysInContextMock.mockImplementation(() => initialState)

const { queryByTestId } = render(<KeysTreeHeader />)
const { queryByTestId } = render(<KeysTreeHeader {...mockedProps} />)

expect(queryByTestId('scan-more')).toBeInTheDocument()
})
@@ -45,7 +53,7 @@ describe('KeysTreeHeaders', () => {

useKeysInContextMock.mockImplementation(() => initialState)

const { queryByTestId } = render(<KeysTreeHeader />)
const { queryByTestId } = render(<KeysTreeHeader {...mockedProps} />)

expect(queryByTestId('scan-more')).toBeInTheDocument()
})
Original file line number Diff line number Diff line change
@@ -1,18 +1,23 @@
import React from 'react'
import React, { useEffect, useState } from 'react'
import * as l10n from '@vscode/l10n'

import { ScanMore } from 'uiSrc/components'
import { SCAN_TREE_COUNT_DEFAULT } from 'uiSrc/constants'
import { isDisableScanMore, isShowScanMore, numberWithSpaces } from 'uiSrc/utils'
import { SCAN_TREE_COUNT_DEFAULT, StorageItem } from 'uiSrc/constants'
import { isDisableScanMore, isShowScanMore, numberWithSpaces, sendEventTelemetry, TelemetryEvent } from 'uiSrc/utils'
import { Database } from 'uiSrc/store'
import { sessionStorageService } from 'uiSrc/services'
import { KeysSummary } from '../keys-summary'
import { useKeysApi, useKeysInContext } from '../../hooks/useKeys'

export interface Props {
database: Database
open?: boolean
dbTotal?: number
children: React.ReactNode
}

export const KeysTreeHeader = ({ database }: Props) => {
export const KeysTreeHeader = ({ database, open, dbTotal, children }: Props) => {
const { id: dbId, db: dbIndex } = database
const { loading, total, scanned, nextCursor, resultsLength } = useKeysInContext((state) => ({
loading: state.loading,
scanned: state.data.scanned,
@@ -21,12 +26,33 @@ export const KeysTreeHeader = ({ database }: Props) => {
resultsLength: state.data.keys?.length,
}))

const showTreeInit = open || sessionStorageService.get(`${StorageItem.openTreeNode + dbId + dbIndex}`)
const [showTree, setShowTree] = useState<boolean>(showTreeInit)

const keysApi = useKeysApi()

const loadMoreItems = () => {
keysApi.fetchMorePatternKeysAction(nextCursor, SCAN_TREE_COUNT_DEFAULT)
}

useEffect(() => {
if (showTree) {
sendEventTelemetry({
event: TelemetryEvent.BROWSER_DATABASE_INDEX_CHANGED,
eventData: {
databaseId: dbIndex,
},
})
}
}, [showTree])

const handleToggleShowTree = (value?: boolean) => {
const newShowTree = value ?? !showTree
sessionStorageService.set(`${StorageItem.openTreeNode + dbId + dbIndex}`, newShowTree)

setShowTree(newShowTree)
}

const scannedDisplay = resultsLength > scanned ? resultsLength : scanned
const notAccurateScanned = total
&& scanned >= total
@@ -40,17 +66,21 @@ export const KeysTreeHeader = ({ database }: Props) => {
database={database}
loading={loading}
scanned={scanned}
total={total}
total={dbTotal ?? total}
dbIndex={dbIndex}
resultsLength={resultsLength}
showTree={showTree}
toggleShowTree={handleToggleShowTree}
/>
{isShowScanMore(scanned, total, nextCursor) && (
{isShowScanMore(scanned, total, nextCursor) && showTree && (
<ScanMore
loading={loading}
disabled={isDisableScanMore(scanned, total, nextCursor)}
loadMoreItems={loadMoreItems}
text={l10n.t('({0}{1} Scanned)', notAccurateScanned, numberWithSpaces(scannedDisplay))}
/>
)}
{showTree && children}
</>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import React from 'react'
import { mock } from 'ts-mockito'
import { Mock } from 'vitest'

import { KeyTypes, SelectedKeyActionType } from 'uiSrc/constants'
import * as utils from 'uiSrc/utils'
import { apiService, vscodeApi } from 'uiSrc/services'
import * as useContext from 'uiSrc/store/hooks/use-context/useContext'
import * as useSelectedKeyStore from 'uiSrc/store/hooks/use-selected-key-store/useSelectedKeyStore'
import { Database } from 'uiSrc/store'
import { constants, fireEvent, render, waitForStack } from 'testSrc/helpers'
import { LogicalDatabaseWrapper, Props } from './LogicalDatabaseWrapper'
import * as useKeys from '../../hooks/useKeys'

const mockDatabase = constants.DATABASE
const mockedProps = {
...mock<Props>(<div />),
database: mockDatabase,
}
beforeEach(() => {
apiService.get = vi.fn().mockResolvedValue({ status: 200, data: {} })
})

vi.spyOn(utils, 'sendEventTelemetry')
vi.spyOn(vscodeApi, 'postMessage')

const fnMock = vi.fn()
const addKeyIntoTreeMock = vi.fn()
const deleteKeyFromTreeMock = vi.fn();
(vi.spyOn(useKeys, 'useKeysApi') as Mock).mockImplementation(() => ({
fetchPatternKeysAction: fnMock,
setDatabaseId: fnMock,
setDatabaseIndex: fnMock,
addKeyIntoTree: addKeyIntoTreeMock,
deleteKeyFromTree: deleteKeyFromTreeMock,
}))
const setKeysTreeSortMock = vi.fn()
const resetKeysTreeMock = vi.fn();
(vi.spyOn(useContext, 'useContextApi') as Mock).mockImplementation(() => ({
setKeysTreeSort: setKeysTreeSortMock,
resetKeysTree: resetKeysTreeMock,
}))

describe('LogicalDatabaseWrapper', () => {
it('should render', () => {
expect(render(<LogicalDatabaseWrapper {...mockedProps} />)).toBeTruthy()
})

describe('selectedKeyAction', () => {
const setSelectedKeyActionMock = vi.fn()
const setSelectedKeyMock = vi.fn()
const spySelectedKey = vi.spyOn(useSelectedKeyStore, 'useSelectedKeyStore') as Mock

const selectedKeyAction = {
type: SelectedKeyActionType.Removed,
database: {
id: constants.DATABASE_ID,
},
keyInfo: {
key: constants.KEY_NAME_1,
keyType: KeyTypes.Hash,
},
}

spySelectedKey.mockImplementation(() => ({
setSelectedKeyAction: setSelectedKeyActionMock,
setSelectedKey: setSelectedKeyMock,
selectedKeyAction,
}))

afterEach(() => {
vi.restoreAllMocks()
})

it('should call deleteKeyFromTree and setSelectedKeyAction action after if selected key action is Removed', async () => {
render(<LogicalDatabaseWrapper {...mockedProps} database={{ id: constants.DATABASE_ID } as Database} />)

await waitForStack()

expect(setSelectedKeyActionMock).toBeCalledWith(null)
expect(deleteKeyFromTreeMock).toBeCalledWith(constants.KEY_NAME_1)
})

it('should not call any mocks if database is not equal', async () => {
render(<LogicalDatabaseWrapper {...mockedProps} database={{ id: '123123' } as Database} />)

await waitForStack()

expect(setSelectedKeyActionMock).not.toBeCalled()
expect(deleteKeyFromTreeMock).not.toBeCalled()
expect(addKeyIntoTreeMock).not.toBeCalled()
})

it('should not call any mocks if type is not defined', async () => {
render(<LogicalDatabaseWrapper {...mockedProps} database={{ id: constants.DATABASE_ID } as Database} />)

await waitForStack()

expect(setSelectedKeyActionMock).not.toBeCalled()
expect(deleteKeyFromTreeMock).not.toBeCalled()
expect(addKeyIntoTreeMock).not.toBeCalled()
})

it('should call addKeyIntoTree action after if selected key action is Added', async () => {
const spySelectedKey = vi.spyOn(useSelectedKeyStore, 'useSelectedKeyStore') as Mock

const setSelectedKeyActionMock = vi.fn()
const setSelectedKeyMock = vi.fn()

spySelectedKey.mockImplementation(() => ({
selectedKeyAction: {
...selectedKeyAction,
type: SelectedKeyActionType.Added,
},
setSelectedKeyAction: setSelectedKeyActionMock,
setSelectedKey: setSelectedKeyMock,
}))

render(<LogicalDatabaseWrapper {...mockedProps} database={{ id: constants.DATABASE_ID } as Database} />)

expect(setSelectedKeyMock).toBeCalledWith({ name: constants.KEY_NAME_1 })
expect(setSelectedKeyActionMock).toBeCalledWith(null)
expect(deleteKeyFromTreeMock).not.toBeCalled()
expect(addKeyIntoTreeMock).not.toBeCalled()
})
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import React, { useEffect, useState } from 'react'
import cx from 'classnames'
import { useShallow } from 'zustand/react/shallow'

import { SelectedKeyActionType } from 'uiSrc/constants'
import { Database, useSelectedKeyStore } from 'uiSrc/store'
import { useKeysApi } from '../../hooks/useKeys'

export interface Props {
database: Database
dbIndex?: number
children: React.ReactNode
}

export const LogicalDatabaseWrapper = ({ children, database }: Props) => {
const { id: dbId, db: dbIndex } = database
const { selectedKeyAction, setSelectedKeyAction, setSelectedKey } = useSelectedKeyStore(useShallow((state) => ({
selectedKeyAction: state.action,
setSelectedKeyAction: state.setSelectedKeyAction,
setSelectedKey: state.processSelectedKeySuccess,
})))

const [renderChildren, setRenderChildren] = useState<boolean>(false)

const keysApi = useKeysApi()

useEffect(() => {
keysApi.setDatabaseId(dbId)
keysApi.setDatabaseIndex(dbIndex ?? 0)

setRenderChildren(true)
}, [])

useEffect(() => {
const { type, keyInfo, database: databaseAction } = selectedKeyAction || {}
const { key, keyType, newKey } = keyInfo || {}
const { id: databaseId, db: databaseIndex } = databaseAction || {}

if (!type || (databaseId! + databaseIndex !== dbId + dbIndex)) {
return
}

switch (type) {
case SelectedKeyActionType.Added:
keysApi.addKeyIntoTree(key!, keyType!)
setSelectedKey({ name: key! })
break
case SelectedKeyActionType.Removed:
keysApi.deleteKeyFromTree(key!)
break
case SelectedKeyActionType.Renamed:
keysApi.editKeyName(key!, newKey!)
break
default:
break
}
setSelectedKeyAction(null)
}, [selectedKeyAction])

return (
<div
className={cx('flex w-full flex-col')}
data-testid={`logical-database-${dbId}-${dbIndex}`}
>
{renderChildren && children}
</div>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { LogicalDatabaseWrapper } from './LogicalDatabaseWrapper'
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
.databaseNameWrapper {
@apply flex pl-3.5 cursor-pointer text-ellipsis whitespace-nowrap overflow-hidden items-center;
}

.databaseName {
@apply text-ellipsis whitespace-nowrap overflow-hidden flex items-center;
}

.icon {
@apply mr-2 w-[14px] h-[14px] min-w-[14px] ml-[3px];

&Nested {
@apply m-0;
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
.nodeContainer {
@apply pl-3;
@apply pl-[20px];
border-left: 3px solid transparent;

&:hover {
3 changes: 2 additions & 1 deletion src/webviews/src/modules/keys-tree/hooks/interface.ts
Original file line number Diff line number Diff line change
@@ -4,6 +4,7 @@ import { ZSetMember } from 'uiSrc/modules/key-details/components/zset-details/ho

export interface KeysStore {
databaseId: Nullable<string>
databaseIndex: Nullable<number>
loading: boolean
deleting: boolean
isFiltered: boolean
@@ -28,10 +29,10 @@ export interface KeysActions {
// Add key
addKey: () => void
addKeyFinal: () => void
addKeySuccess: (data: KeysStoreData) => void
addKeyToTree: (key: RedisString, keyType: KeyTypes) => void
resetAddKey: () => void
setDatabaseId: (databaseId: string) => void
setDatabaseIndex: (databaseIndex: number) => void

setFilterAndSearch: (filter: Nullable<KeyTypes>, search: string) => void
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { cleanup } from '@testing-library/react'
import { cloneDeep } from 'lodash'
import { Mock, SpyInstance } from 'vitest'
import { createStore } from 'zustand'
import * as utils from 'uiSrc/utils'
@@ -362,6 +360,9 @@ describe('useKeys', () => {
apiService.post = apiServiceMock
const controller = new AbortController()

const dbIndexMock = 2
useKeysStore.setState((state) => ({ ...state, databaseIndex: dbIndexMock }))

// Act
useKeysStore.getState().fetchKeysMetadataTree(
data.map(({ name }, i) => ([i, name])) as any,
@@ -374,7 +375,7 @@ describe('useKeys', () => {
expect(apiServiceMock).toBeCalledWith(
`/databases/null/keys/get-metadata`,
{ keys: data.map(({ name }) => (name)), type: undefined },
{ params: { encoding: 'buffer' }, signal: controller.signal },
{ headers: { "ri-db-index": dbIndexMock }, signal: controller.signal },
)

expect(onSuccessMock).toBeCalledWith(data)
11 changes: 3 additions & 8 deletions src/webviews/src/modules/keys-tree/hooks/useKeysActions.ts
Original file line number Diff line number Diff line change
@@ -7,6 +7,7 @@ import { KeysStore, KeysActions } from './interface'

export const initialKeysState: KeysStore = {
databaseId: null,
databaseIndex: null,
deleting: false,
loading: false,
filter: null,
@@ -93,14 +94,6 @@ KeysStore & KeysActions
addKey: () => set({ loading: true }),
addKeyFinal: () => set({ loading: false }),

addKeySuccess: (data) => set((state) => ({
data: {
...state.data,
previousResultCount: data.keys?.length,
lastRefreshTime: Date.now(),
},
})),

addKeyToTree: (key, type) => set((state) => {
state.data?.keys.unshift({ name: key, type })

@@ -116,6 +109,8 @@ KeysStore & KeysActions

setDatabaseId: (databaseId) => set({ databaseId }),

setDatabaseIndex: (databaseIndex) => set({ databaseIndex }),

setFilterAndSearch: (filter, search = DEFAULT_SEARCH_MATCH) =>
set({ filter, search }),
})
41 changes: 22 additions & 19 deletions src/webviews/src/modules/keys-tree/hooks/useKeysThunks.ts
Original file line number Diff line number Diff line change
@@ -3,11 +3,10 @@ import { StateCreator } from 'zustand'

import { KeyInfo, Nullable, RedisString } from 'uiSrc/interfaces'
import { apiService, sessionStorageService } from 'uiSrc/services'
import { DEFAULT_SEARCH_MATCH, ApiEndpoints, KeyTypes, successMessages, SCAN_TREE_COUNT_DEFAULT, ENDPOINT_BASED_ON_KEY_TYPE, EndpointBasedOnKeyType, StorageItem } from 'uiSrc/constants'
import { DEFAULT_SEARCH_MATCH, ApiEndpoints, KeyTypes, successMessages, SCAN_TREE_COUNT_DEFAULT, ENDPOINT_BASED_ON_KEY_TYPE, EndpointBasedOnKeyType, StorageItem, CustomHeaders } from 'uiSrc/constants'
import {
TelemetryEvent,
getApiErrorMessage,
getEncoding,
getMatchType,
isStatusSuccessful,
sendEventTelemetry,
@@ -22,7 +21,7 @@ import { GetKeysWithDetailsResponse, KeysStore, KeysActions, SetStringWithExpire
import { parseKeysListResponse } from '../utils'

// eslint-disable-next-line import/no-mutable-exports
export let sourceKeysFetch: Nullable<CancelTokenSource> = null
export const sourceKeysFetchStack: Record<string, Nullable<CancelTokenSource>> = {}

export const createKeysThunksSlice: StateCreator<
KeysStore & KeysActions & KeysThunks,
@@ -34,31 +33,32 @@ KeysThunks
fetchPatternKeysAction: async (
cursor: string = '0',
count: number = SCAN_TREE_COUNT_DEFAULT,
telemetryProperties: { [key: string]: any } = {},
telemetryProperties: Record<string, any> = {},
onSuccess?: (data: GetKeysWithDetailsResponse[]) => void,
onFailed?: () => void,
) => {
get().loadKeys()

try {
sourceKeysFetch?.cancel?.()
const { CancelToken } = axios
sourceKeysFetch = CancelToken.source()
const { search: match, filter: type, databaseId, databaseIndex } = get()
const sourceKeysFetchKey = databaseId! + databaseIndex!

const { search: match, filter: type, databaseId } = get()
sourceKeysFetchStack[sourceKeysFetchKey]?.cancel?.()
sourceKeysFetchStack[sourceKeysFetchKey] = CancelToken.source()

const { data, status } = await apiService.post<GetKeysWithDetailsResponse[]>(
getDatabaseUrl(databaseId, ApiEndpoints.KEYS),
{
cursor, count, type, match: match || DEFAULT_SEARCH_MATCH, keysInfo: false,
},
{
params: { encoding: getEncoding() },
cancelToken: sourceKeysFetch.token,
headers: { [CustomHeaders.DbIndex]: databaseIndex },
cancelToken: sourceKeysFetchStack[sourceKeysFetchKey]?.token,
},
)

sourceKeysFetch = null
sourceKeysFetchStack[sourceKeysFetchKey] = null
if (isStatusSuccessful(status)) {
get().loadKeysSuccess(
parseKeysListResponse({}, data),
@@ -105,24 +105,25 @@ KeysThunks
get().loadKeys()

try {
sourceKeysFetch?.cancel?.()

const { CancelToken } = axios
sourceKeysFetch = CancelToken.source()
const { search: match, filter: type, databaseId, databaseIndex } = get()
const sourceKeysFetchKey = databaseId! + databaseIndex!
sourceKeysFetchStack[sourceKeysFetchKey]?.cancel?.()

sourceKeysFetchStack[sourceKeysFetchKey] = CancelToken.source()

const { search: match, filter: type, databaseId } = get()
const { data, status } = await apiService.post(
getDatabaseUrl(databaseId, ApiEndpoints.KEYS),
{
cursor, count, type, match: match || DEFAULT_SEARCH_MATCH, keysInfo: false,
},
{
params: { encoding: getEncoding() },
cancelToken: sourceKeysFetch.token,
headers: { [CustomHeaders.DbIndex]: databaseIndex },
cancelToken: sourceKeysFetchStack[sourceKeysFetchKey]?.token,
},
)

sourceKeysFetch = null
sourceKeysFetchStack[sourceKeysFetchKey] = null
if (isStatusSuccessful(status)) {
const newKeysData = parseKeysListResponse(
get().data.shardsMeta,
@@ -159,7 +160,9 @@ KeysThunks
const { data } = await apiService.post<KeyInfo[]>(
getDatabaseUrl(get().databaseId, ApiEndpoints.KEYS_METADATA),
{ keys: keys.map(([,nameBuffer]) => nameBuffer), type: get().filter || undefined },
{ params: { encoding: getEncoding() }, signal },
{
headers: { [CustomHeaders.DbIndex]: get().databaseIndex }, signal,
},
)

const newData = data.map((key, i) => ({ ...key, path: keys[i][0] || 0 })) as KeyInfo[]
@@ -184,7 +187,7 @@ KeysThunks
getDatabaseUrl(get().databaseId, ApiEndpoints.KEYS),
{
data: { keyNames: [key] },
params: { encoding: getEncoding() },
headers: { [CustomHeaders.DbIndex]: get().databaseIndex },
},
)

Loading