├── packages
├── react-cli
│ ├── src
│ │ ├── index.ts
│ │ ├── playground.tsx
│ │ └── cli.css
│ ├── img
│ │ └── cli.png
│ ├── tsup.config.js
│ ├── vite.config.ts
│ ├── index.html
│ ├── package.json
│ ├── README.md
│ └── CHANGELOG.md
└── react-databrowser
│ ├── src
│ ├── index.ts
│ ├── types
│ │ └── index.ts
│ ├── components
│ │ ├── ui
│ │ │ ├── skeleton.tsx
│ │ │ ├── label.tsx
│ │ │ ├── toaster.tsx
│ │ │ ├── input.tsx
│ │ │ ├── separator.tsx
│ │ │ ├── textarea.tsx
│ │ │ ├── checkbox.tsx
│ │ │ ├── spinner.tsx
│ │ │ ├── tooltip.tsx
│ │ │ ├── popover.tsx
│ │ │ ├── badge.tsx
│ │ │ ├── scroll-area.tsx
│ │ │ ├── button.tsx
│ │ │ ├── table.tsx
│ │ │ ├── use-toast.ts
│ │ │ ├── alert-dialog.tsx
│ │ │ ├── dialog.tsx
│ │ │ ├── toast.tsx
│ │ │ └── select.tsx
│ │ └── databrowser
│ │ │ ├── hooks
│ │ │ ├── index.ts
│ │ │ ├── useDebounce.ts
│ │ │ ├── useFetchTTLBy.ts
│ │ │ ├── useDeleteKey.ts
│ │ │ ├── useAddData.ts
│ │ │ ├── useUpdateTTL.ts
│ │ │ ├── useUpdateStringAndJSON.ts
│ │ │ ├── useFetchSingleDataByKey
│ │ │ │ ├── utils.ts
│ │ │ │ ├── index.ts
│ │ │ │ └── fetch-data-types.ts
│ │ │ └── useFetchPaginatedKeys.ts
│ │ │ ├── components
│ │ │ ├── sidebar
│ │ │ │ ├── skeleton-buttons.tsx
│ │ │ │ ├── sidebar-missing-data.tsx
│ │ │ │ ├── display-db-size.tsx
│ │ │ │ ├── reload-button.tsx
│ │ │ │ ├── data-type-selector.tsx
│ │ │ │ ├── data-key-buttons.tsx
│ │ │ │ └── index.tsx
│ │ │ ├── icons
│ │ │ │ └── icon-braces.tsx
│ │ │ ├── data-display-container
│ │ │ │ ├── data-loading.tsx
│ │ │ │ ├── index.tsx
│ │ │ │ ├── data-ttl-actions.tsx
│ │ │ │ ├── delete-alert-dialog.tsx
│ │ │ │ ├── missing-data-display.tsx
│ │ │ │ ├── data-delete.tsx
│ │ │ │ ├── display-scrollarea.tsx
│ │ │ │ ├── data-value-edit.tsx
│ │ │ │ ├── ttl-popover.tsx
│ │ │ │ ├── data-table.tsx
│ │ │ │ └── data-display.tsx
│ │ │ └── add-data
│ │ │ │ └── add-data-dialog.tsx
│ │ │ ├── type-tag.tsx
│ │ │ ├── index.tsx
│ │ │ └── copy-to-clipboard-button.tsx
│ ├── globals.css
│ ├── playground.tsx
│ ├── store.tsx
│ └── lib
│ │ ├── utils.ts
│ │ └── clients.ts
│ ├── postcss.config.js
│ ├── tsup.config.js
│ ├── index.html
│ ├── components.json
│ ├── vite.config.ts
│ ├── tailwind.config.js
│ ├── package.json
│ ├── README.md
│ └── CHANGELOG.md
├── pnpm-workspace.yaml
├── .prettierrc
├── examples
└── nextjs13
│ ├── public
│ ├── favicon.ico
│ ├── vercel.svg
│ ├── thirteen.svg
│ └── next.svg
│ ├── e2e
│ ├── utils
│ │ └── index.ts
│ ├── databrowser.test.ts
│ ├── databrowser-add-delete.test.ts
│ ├── databrowser-string.test.ts
│ ├── databrowser-edit-string.test.ts
│ ├── databrower-pagination.test.ts
│ ├── databrowser-ttl.test.ts
│ ├── databrowser-list.test.ts
│ ├── databrowser-freesearch.test.ts
│ ├── databrowser-set.test.ts
│ ├── databrowser-hash.test.ts
│ ├── databrowser-zset.test.ts
│ ├── databrowser-json.test.ts
│ ├── databrowser-edit-json.test.ts
│ └── databrowser-test-reset-e2e.test.ts
│ ├── next.config.js
│ ├── app
│ ├── head.tsx
│ ├── global.css
│ ├── page.tsx
│ ├── welcome
│ │ └── page.tsx
│ ├── databrowser
│ │ └── page.tsx
│ ├── init
│ │ └── page.tsx
│ ├── layout.tsx
│ └── custom-commands
│ │ └── page.tsx
│ ├── .gitignore
│ ├── tsconfig.json
│ ├── package.json
│ ├── playwright.config.ts
│ └── README.md
├── .husky
├── pre-commit
└── pre-push
├── .changeset
├── config.json
└── README.md
├── turbo.json
├── .gitignore
├── .github
└── workflows
│ ├── main.yaml
│ └── changesets.yaml
├── package.json
├── biome.json
└── README.md
/packages/react-cli/src/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./redis-cli.js";
2 |
--------------------------------------------------------------------------------
/pnpm-workspace.yaml:
--------------------------------------------------------------------------------
1 | packages:
2 | - packages/*
3 | - examples/*
--------------------------------------------------------------------------------
/packages/react-databrowser/src/index.ts:
--------------------------------------------------------------------------------
1 | export { Databrowser } from "@/components/databrowser";
2 |
--------------------------------------------------------------------------------
/packages/react-cli/img/cli.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/upstash/react-ui/main/packages/react-cli/img/cli.png
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "plugins": ["prettier-plugin-tailwindcss"],
3 | "printWidth": 120,
4 | "useTabs": false
5 | }
--------------------------------------------------------------------------------
/examples/nextjs13/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/upstash/react-ui/main/examples/nextjs13/public/favicon.ico
--------------------------------------------------------------------------------
/examples/nextjs13/e2e/utils/index.ts:
--------------------------------------------------------------------------------
1 | export const generateRandomString = () => (Math.random() + 1).toString(36).substring(7);
2 |
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 | . "$(dirname -- "$0")/_/husky.sh"
3 |
4 | if [ -z "$DISABLE_HUSKY" ]; then
5 | npx lint-staged
6 | fi
--------------------------------------------------------------------------------
/.husky/pre-push:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 | . "$(dirname -- "$0")/_/husky.sh"
3 |
4 |
5 |
6 | if [ -z "$DISABLE_HUSKY" ]; then
7 | pnpm test
8 | fi
--------------------------------------------------------------------------------
/packages/react-databrowser/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | };
7 |
--------------------------------------------------------------------------------
/examples/nextjs13/next.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | experimental: {
4 | appDir: true,
5 | },
6 | };
7 |
8 | module.exports = nextConfig;
9 |
--------------------------------------------------------------------------------
/packages/react-databrowser/src/types/index.ts:
--------------------------------------------------------------------------------
1 | export const RedisDataTypes = ["All Types", "string", "list", "hash", "set", "zset", "json", "stream"] as const;
2 |
3 | export type RedisDataTypeUnion = (typeof RedisDataTypes)[number];
4 | export type ActionVariants = "reset" | "filter" | "search" | "next" | "prev";
5 |
--------------------------------------------------------------------------------
/packages/react-databrowser/tsup.config.js:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "tsup";
2 |
3 | export default defineConfig({
4 | entry: ["src/index.ts"],
5 | format: ["cjs", "esm"],
6 | splitting: true,
7 | sourcemap: false,
8 | clean: true,
9 | dts: true,
10 | minify: true,
11 | minifyWhitespace: true,
12 | });
13 |
--------------------------------------------------------------------------------
/.changeset/config.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://unpkg.com/@changesets/config@2.3.0/schema.json",
3 | "changelog": "@changesets/cli/changelog",
4 | "commit": false,
5 | "fixed": [],
6 | "linked": [],
7 | "access": "restricted",
8 | "baseBranch": "main",
9 | "updateInternalDependencies": "patch",
10 | "ignore": []
11 | }
12 |
--------------------------------------------------------------------------------
/packages/react-cli/tsup.config.js:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "tsup";
2 |
3 | export default defineConfig({
4 | entry: ["src/index.ts"],
5 | format: ["cjs", "esm"],
6 | splitting: false,
7 | sourcemap: false,
8 | clean: true,
9 | bundle: true,
10 | dts: true,
11 | minify: true,
12 | minifyWhitespace: true,
13 | });
14 |
--------------------------------------------------------------------------------
/packages/react-databrowser/src/components/ui/skeleton.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@/lib/utils";
2 |
3 | function Skeleton({ className, ...props }: React.HTMLAttributes) {
4 | return (
5 |
6 | );
7 | }
8 |
9 | export { Skeleton };
10 |
--------------------------------------------------------------------------------
/examples/nextjs13/app/head.tsx:
--------------------------------------------------------------------------------
1 | export default function Head() {
2 | return (
3 | <>
4 | Create Next App
5 |
6 |
7 |
8 | >
9 | );
10 | }
11 |
--------------------------------------------------------------------------------
/packages/react-cli/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig, loadEnv } from "vite";
2 | import react from "@vitejs/plugin-react";
3 |
4 | export default ({ mode }: { mode: string }) => {
5 | const env = loadEnv(mode, process.cwd(), "");
6 | return defineConfig({
7 | define: {
8 | "process.env": env,
9 | },
10 | plugins: [react()],
11 | });
12 | };
13 |
--------------------------------------------------------------------------------
/packages/react-databrowser/src/components/databrowser/hooks/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./useAddData";
2 | export * from "./useDebounce";
3 | export * from "./useDeleteKey";
4 | export * from "./useFetchPaginatedKeys";
5 | export * from "./useFetchSingleDataByKey";
6 | export * from "./useFetchTTLBy";
7 | export * from "./useUpdateStringAndJSON";
8 | export * from "./useUpdateTTL";
9 |
--------------------------------------------------------------------------------
/packages/react-cli/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | CLI Playground
7 |
8 |
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/turbo.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://turbo.build/schema.json",
3 | "pipeline": {
4 | "build": {
5 | "dependsOn": ["^build"],
6 | "outputs": ["dist/**", ".next/**"]
7 | },
8 | "dev": {
9 | "dependsOn": ["^build"],
10 | "cache": false
11 | },
12 | "test": {
13 | "dependsOn": ["^build"],
14 | "cache": false
15 | }
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/packages/react-databrowser/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Databrowser Playground
7 |
8 |
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/packages/react-databrowser/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "new-york",
4 | "rsc": false,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.js",
8 | "css": "app/globals.css",
9 | "baseColor": "neutral",
10 | "cssVariables": false
11 | },
12 | "aliases": {
13 | "components": "@/components",
14 | "utils": "@/lib/utils"
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/packages/react-databrowser/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig, loadEnv } from "vite";
2 | import react from "@vitejs/plugin-react";
3 | import tsconfigPaths from "vite-tsconfig-paths";
4 |
5 | export default ({ mode }: { mode: string }) => {
6 | const env = loadEnv(mode, process.cwd(), "");
7 | return defineConfig({
8 | define: {
9 | "process.env": env,
10 | },
11 | plugins: [react(), tsconfigPaths()],
12 | });
13 | };
14 |
--------------------------------------------------------------------------------
/packages/react-databrowser/src/components/databrowser/components/sidebar/skeleton-buttons.tsx:
--------------------------------------------------------------------------------
1 | import { Skeleton } from "@/components/ui/skeleton";
2 |
3 | const DEFAULT_SKELETON_COUNT = 10;
4 | export const LoadingSkeleton = () => (
5 |
6 | {Array(DEFAULT_SKELETON_COUNT)
7 | .fill(0)
8 | .map((_, idx) => (
9 |
10 | ))}
11 |
12 | );
13 |
--------------------------------------------------------------------------------
/packages/react-databrowser/src/components/databrowser/hooks/useDebounce.ts:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from "react";
2 |
3 | export function useDebounce(value: T, delay: number): T {
4 | const [debouncedValue, setDebouncedValue] = useState(value);
5 |
6 | useEffect(() => {
7 | const handler = setTimeout(() => {
8 | setDebouncedValue(value);
9 | }, delay);
10 |
11 | return () => {
12 | clearTimeout(handler);
13 | };
14 | }, [value, delay]);
15 |
16 | return debouncedValue;
17 | }
18 |
--------------------------------------------------------------------------------
/.changeset/README.md:
--------------------------------------------------------------------------------
1 | # Changesets
2 |
3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
4 | with multi-package repos, or single-package repos to help you version and publish your code. You can
5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets)
6 |
7 | We have a quick list of common questions to get you started engaging with this project in
8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)
9 |
--------------------------------------------------------------------------------
/examples/nextjs13/app/global.css:
--------------------------------------------------------------------------------
1 | .nav-container {
2 | display: flex;
3 | gap: 20px;
4 | padding: 10px 20px;
5 | background-color: #F5F5F5;
6 | margin-bottom: 20px;
7 | border-radius: 0 !important;;
8 | }
9 |
10 | .nav-link {
11 | padding: 8px 16px;
12 | color: black;
13 | text-decoration: none;
14 | border-radius: 4px;
15 | background-color:#cfceced0;
16 | transition:
17 | background-color 0.3s ease,
18 | color 0.3s ease;
19 | }
20 |
21 | .nav-link:hover {
22 | background-color: #555;
23 | color: #eee;
24 | }
25 |
--------------------------------------------------------------------------------
/examples/nextjs13/e2e/databrowser.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, test } from "@playwright/test";
2 |
3 | test("Check the missing data text", async ({ page }) => {
4 | await page.goto("http://localhost:3000/databrowser");
5 | // Wait for the element to be in the DOM.
6 | const missingDataElement = page.locator('[data-testid="missing-data"]');
7 |
8 | // Get the text content of the element and compare it to the expected value.
9 | const missingDataText = await missingDataElement.textContent();
10 | expect(missingDataText).toBe("Select a record from the list");
11 | });
12 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 | .env
3 |
4 | # dependencies
5 | **node_modules
6 | /.pnp
7 | .pnp.js
8 |
9 | # testing
10 | /coverage
11 |
12 | # next.js
13 | /.next/
14 | /out/
15 |
16 | # production
17 | /build
18 |
19 | # misc
20 | .DS_Store
21 | *.pem
22 |
23 | # debug
24 | npm-debug.log*
25 | yarn-debug.log*
26 | yarn-error.log*
27 | .pnpm-debug.log*
28 |
29 | # local env files
30 | .env*.local
31 |
32 | # vercel
33 | .vercel
34 |
35 | # typescript
36 | *.tsbuildinfo
37 | next-env.d.ts
38 |
39 | .turbo
40 | dist
41 |
--------------------------------------------------------------------------------
/examples/nextjs13/app/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { RedisCli } from "@upstash/react-cli";
3 |
4 | export default function Home() {
5 | const upstashRedisRestUrl = process.env.NEXT_PUBLIC_UPSTASH_REDIS_REST_URL;
6 | if (!upstashRedisRestUrl) {
7 | return UPSTASH_REDIS_REST_URL not set
;
8 | }
9 | const upstashRedisRestToken = process.env.NEXT_PUBLIC_UPSTASH_REDIS_REST_TOKEN;
10 | if (!upstashRedisRestToken) {
11 | return UPSTASH_REDIS_REST_TOKEN not set
;
12 | }
13 |
14 | return ;
15 | }
16 |
--------------------------------------------------------------------------------
/examples/nextjs13/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # next.js
12 | /.next/
13 | /out/
14 |
15 | # production
16 | /build
17 |
18 | # misc
19 | .DS_Store
20 | *.pem
21 |
22 | # debug
23 | npm-debug.log*
24 | yarn-debug.log*
25 | yarn-error.log*
26 | .pnpm-debug.log*
27 |
28 | # local env files
29 | .env*.local
30 |
31 | # vercel
32 | .vercel
33 |
34 | # typescript
35 | *.tsbuildinfo
36 | next-env.d.ts
37 |
38 | .env*
39 |
40 | # vscode
41 |
42 | .vscode
--------------------------------------------------------------------------------
/examples/nextjs13/app/welcome/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { RedisCli } from "@upstash/react-cli";
3 |
4 | export default function Home() {
5 | const upstashRedisRestUrl = process.env.NEXT_PUBLIC_UPSTASH_REDIS_REST_URL;
6 | if (!upstashRedisRestUrl) {
7 | return UPSTASH_REDIS_REST_URL not set
;
8 | }
9 | const upstashRedisRestToken = process.env.NEXT_PUBLIC_UPSTASH_REDIS_REST_TOKEN;
10 | if (!upstashRedisRestToken) {
11 | return UPSTASH_REDIS_REST_TOKEN not set
;
12 | }
13 |
14 | return Custom} />;
15 | }
16 |
--------------------------------------------------------------------------------
/examples/nextjs13/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.github/workflows/main.yaml:
--------------------------------------------------------------------------------
1 | name: Build
2 |
3 | on:
4 | push:
5 | branches:
6 | - "**"
7 |
8 | jobs:
9 | Build:
10 | runs-on: ubuntu-latest
11 | steps:
12 | - name: Checkout Repo
13 | uses: actions/checkout@v3
14 |
15 | - name: Use PNPM
16 | uses: pnpm/action-setup@v3.0.0
17 | with:
18 | version: 9.0.0
19 |
20 | - name: Use Node.js 18
21 | uses: actions/setup-node@v3
22 | with:
23 | node-version: 18
24 | cache: "pnpm"
25 |
26 | - name: Install
27 | run: pnpm install
28 |
29 | - name: Build for production
30 | run: pnpm build
31 |
--------------------------------------------------------------------------------
/packages/react-databrowser/src/components/databrowser/hooks/useFetchTTLBy.ts:
--------------------------------------------------------------------------------
1 | import { useDatabrowser } from "@/store";
2 | import { useQuery } from "@tanstack/react-query";
3 |
4 | export const useFetchTTLByKey = (dataKey?: string) => {
5 | const { redis } = useDatabrowser();
6 |
7 | const { isLoading, error, data } = useQuery({
8 | queryKey: ["useFetchTTLByKey", dataKey],
9 | queryFn: async () => {
10 | if (dataKey === undefined) {
11 | throw new Error("Key is missing!");
12 | }
13 | const stringValue = await redis.ttl(dataKey);
14 | return stringValue;
15 | },
16 | });
17 | return { isLoading, error, data };
18 | };
19 |
--------------------------------------------------------------------------------
/packages/react-databrowser/src/components/databrowser/components/icons/icon-braces.tsx:
--------------------------------------------------------------------------------
1 | export function IconBraces() {
2 | return (
3 |
17 | );
18 | }
19 |
--------------------------------------------------------------------------------
/examples/nextjs13/app/databrowser/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { Databrowser } from "@upstash/react-databrowser";
3 | import "@upstash/react-databrowser/dist/index.css";
4 |
5 | export default function DatabrowserDemo() {
6 | const upstashRedisRestUrl = process.env.NEXT_PUBLIC_UPSTASH_REDIS_REST_URL;
7 | if (!upstashRedisRestUrl) {
8 | return UPSTASH_REDIS_REST_URL not set
;
9 | }
10 | const upstashRedisRestToken = process.env.NEXT_PUBLIC_UPSTASH_REDIS_REST_TOKEN;
11 | if (!upstashRedisRestToken) {
12 | return UPSTASH_REDIS_REST_TOKEN not set
;
13 | }
14 |
15 | return ;
16 | }
17 |
--------------------------------------------------------------------------------
/packages/react-databrowser/src/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @layer components {
6 | .ttl-with-gray-bg {
7 | @apply flex h-[25px] w-[120px] items-center justify-center gap-[2px] rounded-md bg-[#00000008] px-2 py-1 text-sm text-[#00000099];
8 | }
9 |
10 | .save-changes-btn {
11 | @apply inline-flex h-10 items-center justify-center rounded-md border border-neutral-200 bg-green-600 px-4 py-2 text-sm font-medium text-white ring-offset-white transition-colors hover:bg-green-600/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-950 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/packages/react-databrowser/src/components/databrowser/hooks/useDeleteKey.ts:
--------------------------------------------------------------------------------
1 | import { queryClient } from "@/lib/clients";
2 | import { useDatabrowser } from "@/store";
3 | import { useMutation } from "@tanstack/react-query";
4 |
5 | export const useDeleteKey = () => {
6 | const { redis } = useDatabrowser();
7 |
8 | const deleteKey = useMutation({
9 | mutationFn: async (dataKey?: string) => {
10 | if (dataKey === undefined) {
11 | throw new Error("Key is missing!");
12 | }
13 |
14 | return Boolean(await redis.del(dataKey));
15 | },
16 | onSuccess: () =>
17 | queryClient.invalidateQueries({
18 | queryKey: ["useFetchPaginatedKeys"],
19 | }),
20 | });
21 |
22 | return deleteKey;
23 | };
24 |
--------------------------------------------------------------------------------
/packages/react-databrowser/src/components/databrowser/components/sidebar/sidebar-missing-data.tsx:
--------------------------------------------------------------------------------
1 | export const SidebarMissingData = () => {
2 | return (
3 |
4 |
5 |
6 |
7 |
Data on a break
8 |
9 | "Quick, lure it back with some CLI magic!"
10 |
11 |
12 |
13 |
14 |
15 | );
16 | };
17 |
--------------------------------------------------------------------------------
/examples/nextjs13/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "forceConsistentCasingInFileNames": true,
9 | "noEmit": true,
10 | "esModuleInterop": true,
11 | "module": "esnext",
12 | "moduleResolution": "node",
13 | "resolveJsonModule": true,
14 | "isolatedModules": true,
15 | "jsx": "preserve",
16 | "incremental": true,
17 | "plugins": [
18 | {
19 | "name": "next"
20 | }
21 | ],
22 | "baseUrl": ".",
23 | "paths": {
24 | "@/*": ["./*"]
25 | }
26 | },
27 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
28 | "exclude": ["node_modules"]
29 | }
30 |
--------------------------------------------------------------------------------
/packages/react-databrowser/src/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import * as LabelPrimitive from "@radix-ui/react-label";
3 | import { cva, type VariantProps } from "class-variance-authority";
4 |
5 | import { cn } from "@/lib/utils";
6 |
7 | const labelVariants = cva("text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70");
8 |
9 | const Label = React.forwardRef<
10 | React.ElementRef,
11 | React.ComponentPropsWithoutRef & VariantProps
12 | >(({ className, ...props }, ref) => (
13 |
14 | ));
15 | Label.displayName = LabelPrimitive.Root.displayName;
16 |
17 | export { Label };
18 |
--------------------------------------------------------------------------------
/examples/nextjs13/app/init/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { RedisCli } from "@upstash/react-cli";
3 |
4 | export default function Home() {
5 | const upstashRedisRestUrl = process.env.NEXT_PUBLIC_UPSTASH_REDIS_REST_URL;
6 | if (!upstashRedisRestUrl) {
7 | return UPSTASH_REDIS_REST_URL not set
;
8 | }
9 | const upstashRedisRestToken = process.env.NEXT_PUBLIC_UPSTASH_REDIS_REST_TOKEN;
10 | if (!upstashRedisRestToken) {
11 | return UPSTASH_REDIS_REST_TOKEN not set
;
12 | }
13 |
14 | return (
15 |
25 | );
26 | }
27 |
--------------------------------------------------------------------------------
/packages/react-databrowser/src/components/ui/toaster.tsx:
--------------------------------------------------------------------------------
1 | import { Toast, ToastClose, ToastDescription, ToastProvider, ToastTitle, ToastViewport } from "@/components/ui/toast";
2 | import { useToast } from "@/components/ui/use-toast";
3 |
4 | export function Toaster() {
5 | const { toasts } = useToast();
6 |
7 | return (
8 |
9 | {toasts.map(({ id, title, description, action, ...props }) => (
10 |
11 |
12 | {title && {title}}
13 | {description && {description}}
14 |
15 | {action}
16 |
17 |
18 | ))}
19 |
20 |
21 | );
22 | }
23 |
--------------------------------------------------------------------------------
/packages/react-databrowser/src/components/databrowser/components/sidebar/display-db-size.tsx:
--------------------------------------------------------------------------------
1 | import { Skeleton } from "@/components/ui/skeleton";
2 | import { useDatabrowser } from "@/store";
3 | import { useQuery } from "@tanstack/react-query";
4 |
5 | export const DisplayDbSize = () => {
6 | const { redis } = useDatabrowser();
7 | const { isLoading, error, data } = useQuery({
8 | queryKey: ["useFetchDbSize"],
9 | queryFn: async () => {
10 | return await redis.dbsize();
11 | },
12 | });
13 |
14 | if (isLoading || error) {
15 | return (
16 |
17 | Total:
18 |
19 | );
20 | }
21 | return Total: {data}
;
22 | };
23 |
--------------------------------------------------------------------------------
/packages/react-databrowser/src/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | import { cn } from "@/lib/utils";
4 |
5 | export type InputProps = React.InputHTMLAttributes;
6 |
7 | const Input = React.forwardRef(({ className, type, ...props }, ref) => {
8 | return (
9 |
18 | );
19 | });
20 | Input.displayName = "Input";
21 |
22 | export { Input };
23 |
--------------------------------------------------------------------------------
/examples/nextjs13/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "nextjs13",
3 | "version": "0.3.22",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint",
10 | "test": "playwright test"
11 | },
12 | "dependencies": {
13 | "@next/font": "13.3.0",
14 | "@types/node": "18.15.11",
15 | "@types/react": "18.0.37",
16 | "@types/react-dom": "18.0.11",
17 | "@upstash/react-cli": "workspace:*",
18 | "@upstash/react-databrowser": "workspace:*",
19 | "next": "13.3.0",
20 | "react": "18.2.0",
21 | "react-dom": "18.2.0",
22 | "typescript": "5.0.4"
23 | },
24 | "devDependencies": {
25 | "playwright": "^1.38.1",
26 | "@playwright/test": "^1.38.1",
27 | "autoprefixer": "^10.4.14",
28 | "postcss": "^8.4.31",
29 | "tailwindcss": "^3.3.1"
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/packages/react-databrowser/src/components/ui/separator.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import * as SeparatorPrimitive from "@radix-ui/react-separator";
3 |
4 | import { cn } from "@/lib/utils";
5 |
6 | const Separator = React.forwardRef<
7 | React.ElementRef,
8 | React.ComponentPropsWithoutRef
9 | >(({ className, orientation = "horizontal", decorative = true, ...props }, ref) => (
10 |
21 | ));
22 | Separator.displayName = SeparatorPrimitive.Root.displayName;
23 |
24 | export { Separator };
25 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@upstash/react-ui",
3 | "version": "0.0.0",
4 | "private": true,
5 | "scripts": {
6 | "build": "turbo run build",
7 | "dev": "turbo run dev --parallel",
8 | "test": "turbo run test",
9 | "fmt": "pnpm biome check . --apply-unsafe ",
10 | "prepare": "husky install",
11 | "bump-versions": "pnpm changeset version && pnpm install"
12 | },
13 | "devDependencies": {
14 | "@changesets/cli": "^2.26.1",
15 | "husky": "^8.0.1",
16 | "lint-staged": "^13.0.3",
17 | "prettier": "^3.0.3",
18 | "prettier-plugin-tailwindcss": "^0.5.5",
19 | "turbo": "^1.9.3",
20 | "@biomejs/biome": "^1.2.2"
21 | },
22 | "lint-staged": {
23 | "**/*.{js,ts,tsx}": [
24 | "pnpm fmt",
25 | "prettier --write --ignore-unknown"
26 | ]
27 | },
28 | "engines": {
29 | "node": ">=18.0.0"
30 | },
31 | "packageManager": "pnpm@9.0.0"
32 | }
33 |
--------------------------------------------------------------------------------
/packages/react-databrowser/src/components/ui/textarea.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | import { cn } from "@/lib/utils";
4 |
5 | export type TextareaProps = React.TextareaHTMLAttributes;
6 |
7 | const Textarea = React.forwardRef(({ className, ...props }, ref) => {
8 | return (
9 |
17 | );
18 | });
19 | Textarea.displayName = "Textarea";
20 |
21 | export { Textarea };
22 |
--------------------------------------------------------------------------------
/packages/react-databrowser/src/playground.tsx:
--------------------------------------------------------------------------------
1 | import ReactDOM from "react-dom/client";
2 | import { Databrowser } from "@/components/databrowser";
3 |
4 | ReactDOM.createRoot(document.getElementById("root")!).render(
5 |
16 |
26 |
30 |
31 | ,
32 | );
33 |
--------------------------------------------------------------------------------
/biome.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://biomejs.dev/schemas/1.7.0/schema.json",
3 | "organizeImports": {
4 | "enabled": false
5 | },
6 | "linter": {
7 | "enabled": true,
8 | "rules": {
9 | "recommended": true,
10 | "complexity": {
11 | "noForEach": "off"
12 | },
13 | "a11y": {
14 | "noSvgWithoutTitle": "off",
15 | "useButtonType": "off"
16 | },
17 | "correctness": {
18 | "noUnusedVariables": "warn"
19 | },
20 | "security": {
21 | "recommended": true
22 | },
23 | "style": {
24 | "useBlockStatements": "error",
25 | "noNonNullAssertion": "off"
26 | },
27 | "performance": {
28 | "recommended": true
29 | },
30 | "suspicious": {
31 | "noArrayIndexKey": "off"
32 | }
33 | },
34 | "ignore": ["node_modules", ".next", "dist", ".turbo"]
35 | },
36 | "formatter": { "enabled": false }
37 | }
38 |
--------------------------------------------------------------------------------
/packages/react-databrowser/src/components/ui/checkbox.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@/lib/utils";
2 | import React, { type PropsWithChildren } from "react";
3 | import { Button } from "./button";
4 |
5 | type Props = PropsWithChildren<{
6 | checked?: boolean;
7 | onChange?: (checked: boolean) => void;
8 | className?: string;
9 | }>;
10 |
11 | export const Checkbox = React.forwardRef(
12 | ({ checked, onChange, className, children, ...props }, ref) => {
13 | return (
14 |
28 | );
29 | },
30 | );
31 |
--------------------------------------------------------------------------------
/packages/react-databrowser/src/components/databrowser/components/data-display-container/data-loading.tsx:
--------------------------------------------------------------------------------
1 | export const DataLoading = () => {
2 | return (
3 |
12 | );
13 | };
14 |
--------------------------------------------------------------------------------
/packages/react-databrowser/src/components/databrowser/hooks/useAddData.ts:
--------------------------------------------------------------------------------
1 | import { queryClient } from "@/lib/clients";
2 | import { useDatabrowser } from "@/store";
3 | import { useMutation } from "@tanstack/react-query";
4 |
5 | const SUCCESS_MSG = "OK";
6 |
7 | export const useAddData = () => {
8 | const { redis } = useDatabrowser();
9 |
10 | const addData = useMutation({
11 | mutationFn: async ([dataKey, dataValue, ex, isJSON]: [
12 | dataKey: string,
13 | dataValue: string,
14 | ex: number | null,
15 | isJSON: boolean,
16 | ]) => {
17 | if (isJSON) {
18 | const res = await redis.json.set(dataKey, "$", dataValue);
19 | return res === SUCCESS_MSG;
20 | }
21 | const res = await redis.set(dataKey, dataValue, { ...(ex ? { ex } : { keepTtl: true }) });
22 | return res === SUCCESS_MSG;
23 | },
24 | onSuccess: () =>
25 | queryClient.invalidateQueries({
26 | queryKey: ["useFetchPaginatedKeys"],
27 | }),
28 | });
29 | return addData;
30 | };
31 |
--------------------------------------------------------------------------------
/packages/react-databrowser/src/components/databrowser/components/sidebar/reload-button.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@/components/ui/button";
2 | import { ReloadIcon } from "@radix-ui/react-icons";
3 | import { useState } from "react";
4 |
5 | export const ReloadButton = ({
6 | refreshSearch,
7 | refetchData,
8 | }: {
9 | refreshSearch: () => void;
10 | refetchData: () => void;
11 | }) => {
12 | const [isLoading, setIsLoading] = useState(false);
13 |
14 | const handleClick = () => {
15 | setIsLoading(true);
16 | refreshSearch();
17 | refetchData();
18 | setTimeout(() => {
19 | setIsLoading(false);
20 | }, 350);
21 | };
22 |
23 | return (
24 |
25 |
35 |
36 | );
37 | };
38 |
--------------------------------------------------------------------------------
/packages/react-cli/src/playground.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import ReactDOM from "react-dom/client";
3 | import { RedisCli } from "./redis-cli";
4 | import "./cli.css";
5 |
6 | ReactDOM.createRoot(document.getElementById("root")!).render(
7 |
8 |
19 |
29 |
33 |
34 |
35 | ,
36 | );
37 |
--------------------------------------------------------------------------------
/examples/nextjs13/public/thirteen.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/packages/react-databrowser/src/components/ui/spinner.tsx:
--------------------------------------------------------------------------------
1 | import type React from "react";
2 | import type { PropsWithChildren } from "react";
3 |
4 | export interface SpinnerProps extends React.InputHTMLAttributes {
5 | isLoadingText: string;
6 | isLoading: boolean;
7 | }
8 |
9 | export const Spinner = ({ isLoading, className, isLoadingText, children }: PropsWithChildren) => {
10 | return (
11 |
12 | {isLoading ? (
13 | <>
14 |
28 | {isLoadingText}
29 | >
30 | ) : (
31 | children
32 | )}
33 |
34 | );
35 | };
36 |
--------------------------------------------------------------------------------
/examples/nextjs13/e2e/databrowser-add-delete.test.ts:
--------------------------------------------------------------------------------
1 | import { test, expect } from "@playwright/test";
2 |
3 | test("should add new data and delete it succesfully", async ({ page }) => {
4 | await page.goto("http://localhost:3000/databrowser");
5 | await page.getByTestId("add-new-data").click({ delay: 100 });
6 |
7 | await page.getByPlaceholder("Foo").fill("foo");
8 | await page.getByPlaceholder("Foo").press("Tab");
9 | await page.getByPlaceholder("Bar").fill("bar");
10 |
11 | await page.getByRole("combobox").click();
12 | await page.getByLabel("Second(s)").click();
13 | await page.getByPlaceholder("1H is 3600 seconds").click();
14 | await page.getByPlaceholder("1H is 3600 seconds").fill("500");
15 |
16 | await page.getByRole("button", { name: "Save changes" }).click();
17 |
18 | await page.getByTestId("delete").click({ delay: 100 });
19 | await page.getByRole("button", { name: "Delete" }).click();
20 |
21 | const missingDataElement = page.locator('[data-testid="missing-data"]');
22 |
23 | const missingDataText = await missingDataElement.textContent();
24 | expect(missingDataText).toBe("Select a record from the list");
25 | });
26 |
--------------------------------------------------------------------------------
/examples/nextjs13/e2e/databrowser-string.test.ts:
--------------------------------------------------------------------------------
1 | import { test, expect } from "@playwright/test";
2 |
3 | test("should add String data from cli then try to navigate on databrowser", async ({ page }) => {
4 | await page.goto("http://localhost:3000/");
5 |
6 | // Inputting the command to delete the key, to ensure the test starts clean
7 | await page.getByRole("textbox").click();
8 | await page.getByRole("textbox").fill("DEL my_string");
9 | await page.getByRole("textbox").press("Enter");
10 | await page.waitForTimeout(500); //TODO: Dirty hack to wait after delete. Should be fixed later.
11 |
12 | // Inputting the SET command to set a string
13 | const myString = "Hello,thisIsAString";
14 | await page.getByRole("textbox").fill(`SET my_string "${myString}"`);
15 | await page.getByRole("textbox").press("Enter");
16 |
17 | await page.goto("http://localhost:3000/databrowser");
18 | await page.getByPlaceholder("Search").click();
19 | await page.getByPlaceholder("Search").fill("my_string");
20 | await page.getByRole("button", { name: "S my_string" }).click();
21 |
22 | await expect(page.locator(`:text-matches('${myString}')`)).toBeVisible();
23 | });
24 |
--------------------------------------------------------------------------------
/packages/react-databrowser/src/store.tsx:
--------------------------------------------------------------------------------
1 | import { type PropsWithChildren, createContext, useContext, useMemo } from "react";
2 | import type { Redis } from "@upstash/redis";
3 | import { redisClient } from "./lib/clients";
4 |
5 | export type DatabrowserProps = {
6 | url?: string;
7 | token?: string;
8 | };
9 |
10 | type DatabrowserContextProps = {
11 | redis: Redis;
12 | };
13 |
14 | const DatabrowserContext = createContext(undefined);
15 |
16 | interface DatabrowserProviderProps {
17 | databrowser: DatabrowserProps;
18 | }
19 |
20 | export const DatabrowserProvider = ({ children, databrowser }: PropsWithChildren) => {
21 | const redisInstances = useMemo(() => redisClient(databrowser), [databrowser]);
22 | return {children};
23 | };
24 |
25 | export const useDatabrowser = (): DatabrowserContextProps => {
26 | const context = useContext(DatabrowserContext);
27 | if (!context) {
28 | throw new Error("useDatabrowser must be used within a DatabrowserProvider");
29 | }
30 | return context;
31 | };
32 |
--------------------------------------------------------------------------------
/packages/react-databrowser/tailwind.config.js:
--------------------------------------------------------------------------------
1 | const defaultTheme = require("tailwindcss/defaultTheme");
2 |
3 | /** @type {import('tailwindcss').Config} */
4 | module.exports = {
5 | darkMode: ["class"],
6 | content: ["./pages/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}", "./app/**/*.{ts,tsx}", "./src/**/*.{ts,tsx}"],
7 | theme: {
8 | container: {
9 | center: true,
10 | padding: "2rem",
11 | screens: {
12 | "2xl": "1400px",
13 | },
14 | },
15 | extend: {
16 | fontFamily: {
17 | sans: ["Inter var", ...defaultTheme.fontFamily.sans],
18 | },
19 | keyframes: {
20 | "accordion-down": {
21 | from: { height: 0 },
22 | to: { height: "var(--radix-accordion-content-height)" },
23 | },
24 | "accordion-up": {
25 | from: { height: "var(--radix-accordion-content-height)" },
26 | to: { height: 0 },
27 | },
28 | },
29 | animation: {
30 | "accordion-down": "accordion-down 0.2s ease-out",
31 | "accordion-up": "accordion-up 0.2s ease-out",
32 | },
33 | },
34 | },
35 | plugins: [require("tailwindcss-animate")],
36 | };
37 |
--------------------------------------------------------------------------------
/packages/react-databrowser/src/components/databrowser/type-tag.tsx:
--------------------------------------------------------------------------------
1 | import type { RedisDataTypeUnion } from "@/types";
2 | import clsx from "clsx";
3 | import { Badge } from "@/components/ui/badge";
4 |
5 | export interface RedisTypeTagProps extends React.HTMLAttributes {
6 | value: RedisDataTypeUnion;
7 | isFull?: boolean;
8 | }
9 |
10 | export function RedisTypeTag({ value, isFull = false, className }: RedisTypeTagProps) {
11 | return (
12 |
29 | {isFull ? value : value[0]}
30 |
31 | );
32 | }
33 |
--------------------------------------------------------------------------------
/packages/react-cli/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@upstash/react-cli",
3 | "version": "1.0.13",
4 | "main": "./dist/index.js",
5 | "types": "./dist/index.d.ts",
6 | "license": "MIT",
7 | "private": false,
8 | "publishConfig": {
9 | "access": "public"
10 | },
11 | "bugs": {
12 | "url": "https://github.com/upstash/react-ui/issues"
13 | },
14 | "homepage": "https://github.com/upstash/react-ui#readme",
15 | "files": [
16 | "./dist/**"
17 | ],
18 | "author": "Andreas Thomas ",
19 | "scripts": {
20 | "build": "tsup",
21 | "dev": "vite"
22 | },
23 | "devDependencies": {
24 | "@types/node": "^18.15.11",
25 | "@types/react": "^18.0.37",
26 | "@types/react-dom": "^18.0.11",
27 | "autoprefixer": "^10.4.14",
28 | "postcss": "^8.4.31",
29 | "react": "^18.2.0",
30 | "react-dom": "^18.2.0",
31 | "tailwindcss": "^3.3.1",
32 | "tsup": "^6.7.0",
33 | "typescript": "^5.0.4",
34 | "@vitejs/plugin-react": "^4.1.0",
35 | "vite": "^4.4.10"
36 | },
37 | "peerDependencies": {
38 | "react": "^18.2.0",
39 | "react-dom": "^18.2.0"
40 | },
41 | "dependencies": {
42 | "@radix-ui/react-scroll-area": "^1.0.3"
43 | }
44 | }
--------------------------------------------------------------------------------
/packages/react-databrowser/src/components/databrowser/components/data-display-container/index.tsx:
--------------------------------------------------------------------------------
1 | import type { RedisDataTypeUnion } from "@/types";
2 | import { DataDisplay } from "./data-display";
3 | import { MissingDataDisplay } from "./missing-data-display";
4 |
5 | type Props = {
6 | selectedDataKeyTypePair?: [string, RedisDataTypeUnion];
7 | onDataKeyChange: (dataKey?: [string, RedisDataTypeUnion]) => void;
8 | dataFetchTimestamp: number;
9 | };
10 |
11 | export const DataDisplayContainer = ({ selectedDataKeyTypePair, onDataKeyChange, dataFetchTimestamp }: Props) => {
12 | if (!selectedDataKeyTypePair) {
13 | return ;
14 | }
15 |
16 | return (
17 |
29 | );
30 | };
31 |
--------------------------------------------------------------------------------
/examples/nextjs13/e2e/databrowser-edit-string.test.ts:
--------------------------------------------------------------------------------
1 | import { test } from "@playwright/test";
2 | import { generateRandomString } from "./utils";
3 |
4 | test("test", async ({ page }) => {
5 | await page.goto("http://localhost:3000/");
6 |
7 | // Inputting the command to delete the key, to ensure the test starts clean
8 | await page.getByRole("textbox").click();
9 | await page.getByRole("textbox").fill("DEL my_string");
10 | await page.getByRole("textbox").press("Enter");
11 | await page.waitForTimeout(500); //TODO: Dirty hack to wait after delete. Should be fixed later.
12 |
13 | // Inputting the SET command to set a string
14 | const myString = "Hello, this is a string!";
15 | await page.getByRole("textbox").fill(`SET my_string "${myString}"`);
16 | await page.getByRole("textbox").press("Enter");
17 |
18 | const randomString = generateRandomString();
19 |
20 | await page.goto("http://localhost:3000/databrowser");
21 | await page.getByRole("button", { name: "s my_string" }).click();
22 | await page.getByTestId("edit-items-in-place").click();
23 | await page.getByLabel("Editor content;Press Alt+F1 for Accessibility Options.").fill(`"${randomString}"`);
24 | await page.getByTestId("save-items").click();
25 |
26 | await page.getByText(`${randomString}`).click();
27 | });
28 |
--------------------------------------------------------------------------------
/packages/react-databrowser/src/components/databrowser/components/data-display-container/data-ttl-actions.tsx:
--------------------------------------------------------------------------------
1 | import { Skeleton } from "@/components/ui/skeleton";
2 | import { TTLPopover } from "./ttl-popover";
3 |
4 | type Props = {
5 | selectedDataKey: string;
6 | TTLData?: number;
7 | isTTLLoading: boolean;
8 | };
9 |
10 | export const DataTTLActions = ({ selectedDataKey, TTLData, isTTLLoading }: Props) => {
11 | const handleDisplayTTL = () => {
12 | if (TTLData === -1) {
13 | return "Forever";
14 | }
15 | return TTLData ? `${TTLData.toString()}s` : "Missing";
16 | };
17 |
18 | return (
19 |
20 |
21 |
TTL:{" "}
22 | {isTTLLoading ?
:
{handleDisplayTTL()}}
23 |
33 |
34 |
35 | );
36 | };
37 |
--------------------------------------------------------------------------------
/examples/nextjs13/playwright.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig, devices } from "@playwright/test";
2 | import path from "node:path";
3 |
4 | // Use process.env.PORT by default and fallback to port 3000
5 | const PORT = process.env.PORT || 3000;
6 |
7 | // Set webServer.url and use.baseURL with the location of the WebServer respecting the correct set port
8 | const baseURL = `http://localhost:${PORT}`;
9 |
10 | // Reference: https://playwright.dev/docs/test-configuration
11 | export default defineConfig({
12 | // Timeout per test
13 | timeout: 8 * 1000,
14 | // Test directory
15 | testDir: path.join(__dirname, "e2e"),
16 |
17 | retries: 1,
18 |
19 | // There are flushdb commands in the tests, so we need to run them serially
20 | workers: 1,
21 |
22 | // Run your local dev server before starting the tests:
23 | // https://playwright.dev/docs/test-advanced#launching-a-development-web-server-during-the-tests
24 | webServer: {
25 | command: "pnpm dev",
26 | url: baseURL,
27 | timeout: 120 * 1000,
28 | reuseExistingServer: !process.env.CI,
29 | },
30 |
31 | use: {
32 | baseURL,
33 | headless: true,
34 | },
35 |
36 | projects: [
37 | {
38 | name: "Desktop Chrome",
39 | use: {
40 | ...devices["Desktop Chrome"],
41 | },
42 | },
43 | ],
44 | });
45 |
--------------------------------------------------------------------------------
/packages/react-databrowser/src/components/ui/tooltip.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import * as TooltipPrimitive from "@radix-ui/react-tooltip";
3 |
4 | import { cn } from "@/lib/utils";
5 |
6 | const TooltipProvider = TooltipPrimitive.Provider;
7 |
8 | const Tooltip = TooltipPrimitive.Root;
9 |
10 | const TooltipTrigger = TooltipPrimitive.Trigger;
11 |
12 | const TooltipContent = React.forwardRef<
13 | React.ElementRef,
14 | React.ComponentPropsWithoutRef
15 | >(({ className, sideOffset = 4, ...props }, ref) => (
16 |
25 | ));
26 | TooltipContent.displayName = TooltipPrimitive.Content.displayName;
27 |
28 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
29 |
--------------------------------------------------------------------------------
/packages/react-cli/README.md:
--------------------------------------------------------------------------------
1 |
2 |
@upstash/react-cli
3 | CLI for Upstash Redis
4 |
5 |
6 |
9 |
10 |
11 |
12 | 
13 |
14 |
15 | ## 1. Install
16 |
17 | ```sh-session
18 | $ npm install @upstash/react-cli
19 | ```
20 |
21 | ## 2. Add a client component in your app:
22 |
23 | ```tsx
24 | // /app/components/cli.tsx
25 |
26 | "use client"
27 | import { RedisCli } from "@upstash/react-cli";
28 |
29 | import "@upstash/react-cli/dist/index.css";
30 |
31 |
32 |
40 | ;
41 |
42 |
43 | ```
44 |
45 | ## With Tailwind CSS
46 |
47 | If you already have a tailwindcss toolchain, you can omit the css import and add the library to your tailwind config file:
48 |
49 | ```js
50 | // tailwind.config.js
51 |
52 | module.exports = {
53 | content: [
54 | // ...
55 | "./node_modules/@upstash/react-cli/**/*.js", // <-- add this line
56 | ],
57 | //...
58 | }
59 |
60 | ```
--------------------------------------------------------------------------------
/examples/nextjs13/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.github/workflows/changesets.yaml:
--------------------------------------------------------------------------------
1 | # most of this is copied from https://github.com/t3-oss/create-t3-app/blob/next/.github/workflows/release.yml
2 | name: Prepare Release
3 |
4 | on:
5 | push:
6 | branches:
7 | - main
8 |
9 | concurrency: ${{ github.workflow }}-${{ github.ref }}
10 |
11 | jobs:
12 | pr:
13 | name: Release Packages
14 | runs-on: ubuntu-latest
15 | steps:
16 | - name: Checkout Repo
17 | uses: actions/checkout@v3
18 |
19 | - name: Use PNPM
20 | uses: pnpm/action-setup@v3.0.0
21 | with:
22 | version: 9.0.0
23 |
24 | - name: Use Node.js 18
25 | uses: actions/setup-node@v3
26 | with:
27 | node-version: 18
28 | cache: "pnpm"
29 |
30 | - name: Install
31 | run: pnpm install
32 |
33 | - name: Build packages
34 | run: pnpm build
35 |
36 | - name: Create Version PR or Publish to NPM
37 | id: changesets
38 | uses: changesets/action@v1.4.1
39 | with:
40 | commit: "chore(release): version packages"
41 | title: "chore(release): version packages"
42 | version: pnpm bump-versions
43 | publish: pnpm changeset publish
44 | env:
45 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
46 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
47 | DISABLE_HUSKY: true
48 |
--------------------------------------------------------------------------------
/examples/nextjs13/e2e/databrower-pagination.test.ts:
--------------------------------------------------------------------------------
1 | import { test, expect } from "@playwright/test";
2 |
3 | test("should add new string key-values and test pagination", async ({ page }) => {
4 | await page.goto("http://localhost:3000/");
5 |
6 | // Flushes db
7 | await page.getByRole("textbox").click();
8 | await page.getByRole("textbox").fill("flushdb");
9 | await page.getByRole("textbox").press("Enter");
10 | await page.waitForTimeout(500); // TODO: Consider using a more reliable wait condition
11 |
12 | const msetCommand = Array.from({ length: 50 }, (_, i) => `string${i + 1} "This is string ${i + 1}"`).join(" ");
13 | await page.getByRole("textbox").fill(`MSET ${msetCommand}`);
14 | await page.getByRole("textbox").press("Enter");
15 |
16 | await page.goto("http://localhost:3000/databrowser");
17 |
18 | await expect(page.getByRole("button", { name: "s string1", exact: true })).toBeVisible();
19 | await page.getByTestId("sidebar-next").click({ delay: 100 });
20 | await page.getByTestId("sidebar-next").click({ delay: 100 });
21 |
22 | await expect(page.locator('button:has-text("sstring30")').first()).toBeVisible();
23 |
24 | await page.getByTestId("sidebar-prev").click({ delay: 100 });
25 | await page.getByTestId("sidebar-prev").click({ delay: 100 });
26 | await expect(page.getByRole("button", { name: "s string1", exact: true })).toBeVisible();
27 | });
28 |
--------------------------------------------------------------------------------
/examples/nextjs13/e2e/databrowser-ttl.test.ts:
--------------------------------------------------------------------------------
1 | import { test, expect } from "@playwright/test";
2 |
3 | test("should add new data and set ttl", async ({ page }) => {
4 | await page.goto("http://localhost:3000/databrowser");
5 |
6 | await page.getByTestId("add-new-data").click({ delay: 100 });
7 |
8 | await page.getByPlaceholder("Foo").fill("foo");
9 | await page.getByPlaceholder("Foo").press("Tab");
10 | await page.getByPlaceholder("Bar").fill("bar");
11 |
12 | await page.getByRole("combobox").click();
13 | await page.getByLabel("Second(s)").click();
14 | await page.getByPlaceholder("1H is 3600 seconds").click();
15 | await page.getByPlaceholder("1H is 3600 seconds").fill("");
16 |
17 | await page.getByRole("button", { name: "Save changes" }).click();
18 |
19 | const ttlButton = page.locator(':text-matches("^TTL:")');
20 | await ttlButton.click();
21 |
22 | // Adjust TTL to 600s
23 | await page.getByLabel("Seconds").dblclick();
24 | await page.getByLabel("Seconds").press("Meta+a");
25 | await page.getByLabel("Seconds").fill("600");
26 |
27 | // Click outside to apply the TTL and then persist the key
28 | await page.getByText("foo").nth(1).click();
29 | await page.getByText("TTL: 600s").click();
30 | await page.getByRole("button", { name: "Persist Key" }).click();
31 | // Verify that the TTL is now None
32 | await expect(page.getByText("TTL: Forever")).toBeVisible();
33 | });
34 |
--------------------------------------------------------------------------------
/examples/nextjs13/e2e/databrowser-list.test.ts:
--------------------------------------------------------------------------------
1 | import { test, expect } from "@playwright/test";
2 |
3 | test("should add data from cli then try to paginate on databrowser", async ({ page }) => {
4 | await page.goto("http://localhost:3000/");
5 | await page.getByRole("textbox").click();
6 | await page.getByRole("textbox").fill("DEL really_long_list");
7 | await page.getByRole("textbox").press("Enter");
8 | await page.waitForTimeout(500); //TODO: Dirty hack to wait after delete. Should be fixed later.
9 |
10 | await page
11 | .getByRole("textbox")
12 | .fill(`RPUSH really_long_list ${Array.from({ length: 50 }, (_, i) => `item${i + 1}`).join(" ")}`);
13 | await page.getByRole("textbox").press("Enter");
14 | await page.goto("http://localhost:3000/databrowser");
15 | await page.getByPlaceholder("Search").click();
16 | await page.getByPlaceholder("Search").fill("really");
17 | await page.getByRole("button", { name: "L really_long_list" }).click();
18 | await expect(page.getByText("item1", { exact: true })).toBeVisible();
19 |
20 | for (let i = 0; i < 4; i++) {
21 | await page.getByTestId("datatable-next").click({ delay: 100 });
22 | }
23 | await expect(page.getByText("item50", { exact: true })).toBeVisible();
24 |
25 | // Navigate back to find the first item again
26 | for (let i = 0; i < 4; i++) {
27 | await page.getByTestId("datatable-prev").click({ delay: 100 });
28 | }
29 | });
30 |
--------------------------------------------------------------------------------
/packages/react-databrowser/src/components/databrowser/hooks/useUpdateTTL.ts:
--------------------------------------------------------------------------------
1 | import { queryClient } from "@/lib/clients";
2 | import { useDatabrowser } from "@/store";
3 | import { useMutation } from "@tanstack/react-query";
4 |
5 | export const useUpdateTTL = () => {
6 | const { redis } = useDatabrowser();
7 |
8 | const updateTTL = useMutation({
9 | mutationFn: async ({ dataKey, newTTLValue }: { dataKey?: string; newTTLValue: number }) => {
10 | if (dataKey === undefined) {
11 | throw new Error("Key is missing!");
12 | }
13 | return Boolean(await redis.expire(dataKey, newTTLValue));
14 | },
15 | onSuccess: () => {
16 | queryClient.invalidateQueries({ queryKey: ["useFetchSingleDataByKey"] });
17 | queryClient.invalidateQueries({ queryKey: ["useFetchTTLByKey"] });
18 | },
19 | });
20 | return updateTTL;
21 | };
22 |
23 | export const usePersistTTL = () => {
24 | const { redis } = useDatabrowser();
25 |
26 | const persistTTL = useMutation({
27 | mutationFn: async (dataKey?: string) => {
28 | if (dataKey === undefined) {
29 | throw new Error("Key is missing!");
30 | }
31 | return Boolean(await redis.persist(dataKey));
32 | },
33 | onSuccess: () => {
34 | queryClient.invalidateQueries({ queryKey: ["useFetchSingleDataByKey"] });
35 | queryClient.invalidateQueries({ queryKey: ["useFetchTTLByKey"] });
36 | },
37 | });
38 | return persistTTL;
39 | };
40 |
--------------------------------------------------------------------------------
/packages/react-databrowser/src/components/ui/popover.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import * as PopoverPrimitive from "@radix-ui/react-popover";
3 |
4 | import { cn } from "@/lib/utils";
5 |
6 | const Popover = PopoverPrimitive.Root;
7 |
8 | const PopoverTrigger = PopoverPrimitive.Trigger;
9 |
10 | const PopoverContent = React.forwardRef<
11 | React.ElementRef,
12 | React.ComponentPropsWithoutRef
13 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
14 |
15 |
25 |
26 | ));
27 | PopoverContent.displayName = PopoverPrimitive.Content.displayName;
28 |
29 | export { Popover, PopoverTrigger, PopoverContent };
30 |
--------------------------------------------------------------------------------
/examples/nextjs13/e2e/databrowser-freesearch.test.ts:
--------------------------------------------------------------------------------
1 | import { test, expect } from "@playwright/test";
2 |
3 | test("should add data, search, and validate the search functionality", async ({ page }) => {
4 | await page.goto("http://localhost:3000/databrowser");
5 |
6 | // Clicking on the "Add" button to open the form to add new data
7 | await page.getByTestId("add-new-data").click({ delay: 100 });
8 |
9 | // Filling the form with "foo" and "bar" and setting the time to "400" seconds
10 | await page.fill('[placeholder="Foo"]', "foo");
11 | await page.press('[placeholder="Foo"]', "Tab");
12 | await page.fill('[placeholder="Bar"]', "bar");
13 | await page.press('[placeholder="Bar"]', "Tab");
14 | await page.getByRole("combobox").click();
15 | await page.getByLabel("Second(s)").click();
16 | await page.fill('[placeholder="1H is 3600 seconds"]', "400");
17 |
18 | // Saving the changes
19 | await page.getByRole("button", { name: "Save changes" }).click();
20 |
21 | // Searching for the newly added data "foo"
22 | await page.click('[placeholder="Search"]');
23 | await page.fill('[placeholder="Search"]', "foo");
24 | await page.getByRole("button", { name: "S foo" }).click();
25 |
26 | await page.getByTestId("reset").click();
27 |
28 | // Validating that the search input is not empty after click
29 | await page.click('[placeholder="Search"]');
30 | const inputValue = await page.inputValue('[placeholder="Search"]');
31 | expect(inputValue).toBe("foo");
32 | });
33 |
--------------------------------------------------------------------------------
/packages/react-databrowser/src/components/ui/badge.tsx:
--------------------------------------------------------------------------------
1 | import type * as React from "react";
2 | import { cva, type VariantProps } from "class-variance-authority";
3 |
4 | import { cn } from "@/lib/utils";
5 |
6 | const badgeVariants = cva(
7 | "inline-flex items-center rounded-full border border-neutral-200 px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-neutral-950 focus:ring-offset-2 dark:border-neutral-800 dark:focus:ring-neutral-300",
8 | {
9 | variants: {
10 | variant: {
11 | default:
12 | "border-transparent bg-neutral-900 text-neutral-50 hover:bg-neutral-900/80 dark:bg-neutral-50 dark:text-neutral-900 dark:hover:bg-neutral-50/80",
13 | secondary:
14 | "border-transparent bg-neutral-100 text-neutral-900 hover:bg-neutral-100/80 dark:bg-neutral-800 dark:text-neutral-50 dark:hover:bg-neutral-800/80",
15 | destructive:
16 | "border-transparent bg-red-500 text-neutral-50 hover:bg-red-500/80 dark:bg-red-900 dark:text-neutral-50 dark:hover:bg-red-900/80",
17 | outline: "text-neutral-950 dark:text-neutral-50",
18 | },
19 | },
20 | defaultVariants: {
21 | variant: "default",
22 | },
23 | },
24 | );
25 |
26 | export interface BadgeProps extends React.HTMLAttributes, VariantProps {}
27 |
28 | function Badge({ className, variant, ...props }: BadgeProps) {
29 | return ;
30 | }
31 |
32 | export { Badge, badgeVariants };
33 |
--------------------------------------------------------------------------------
/packages/react-databrowser/src/components/databrowser/components/data-display-container/delete-alert-dialog.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | AlertDialog,
3 | AlertDialogAction,
4 | AlertDialogCancel,
5 | AlertDialogContent,
6 | AlertDialogDescription,
7 | AlertDialogFooter,
8 | AlertDialogHeader,
9 | AlertDialogTitle,
10 | AlertDialogTrigger,
11 | } from "@/components/ui/alert-dialog";
12 | import type { PropsWithChildren } from "react";
13 |
14 | export function DeleteAlertDialog({ children, onDeleteConfirm }: PropsWithChildren<{ onDeleteConfirm: () => void }>) {
15 | return (
16 |
17 | {children}
18 |
19 |
20 | Irreversible Action!
21 |
22 | This action CANNOT BE UNDONE.
23 |
24 |
25 | By proceeding, you will PERMANENTLY REMOVE your data from our servers,
26 | resulting in complete and irreversible loss of your information.
27 |
28 |
29 |
30 | Cancel
31 |
32 | Delete
33 |
34 |
35 |
36 |
37 | );
38 | }
39 |
--------------------------------------------------------------------------------
/packages/react-databrowser/src/components/databrowser/components/data-display-container/missing-data-display.tsx:
--------------------------------------------------------------------------------
1 | export const MissingDataDisplay = () => {
2 | return (
3 |
4 |
5 |
6 |
7 |
18 | {/*TODO: Add CLI link here to easy navigation */}
19 |
20 | Select a record from the list
21 |
22 |
23 |
24 |
25 |
26 | );
27 | };
28 |
--------------------------------------------------------------------------------
/packages/react-databrowser/src/components/databrowser/hooks/useUpdateStringAndJSON.ts:
--------------------------------------------------------------------------------
1 | import { queryClient } from "@/lib/clients";
2 | import type { RedisDataTypeUnion } from "@/types";
3 | import { useState } from "react";
4 | import { useAddData } from "./useAddData";
5 |
6 | export const useUpdateStringAndJSON = ([key, keyType]: [string, RedisDataTypeUnion], TTLData: number | undefined) => {
7 | const { mutateAsync: replaceData, status: updateDataStatus } = useAddData();
8 |
9 | const [isContentEditable, setIsContentEditable] = useState(false);
10 | const [updatedContent, setUpdatedContent] = useState();
11 |
12 | const handleUpdatedContent = (text?: string) => {
13 | setUpdatedContent(text);
14 | };
15 | const handleContentEditableToggle = () => {
16 | setIsContentEditable((prevState) => !prevState);
17 | };
18 |
19 | const handleContentUpdate = async () => {
20 | const isDataTypeJSON = keyType === "json";
21 | const isPersistedTTL = TTLData === -1;
22 | if (!updatedContent) {
23 | return;
24 | }
25 |
26 | await replaceData([key, updatedContent, isPersistedTTL || !TTLData ? null : TTLData, isDataTypeJSON]);
27 | queryClient.invalidateQueries({
28 | queryKey: ["useFetchSingleDataByKey"],
29 | });
30 | queryClient.invalidateQueries({
31 | queryKey: ["useFetchTTLByKey"],
32 | });
33 | setIsContentEditable(false);
34 | setUpdatedContent(undefined);
35 | };
36 |
37 | return {
38 | handleContentUpdate,
39 | updateDataStatus,
40 | handleContentEditableToggle,
41 | handleUpdatedContent,
42 | isContentEditable,
43 | };
44 | };
45 |
--------------------------------------------------------------------------------
/examples/nextjs13/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import "@upstash/react-cli/dist/index.css";
2 | import { Inter } from "next/font/google";
3 | import "./global.css";
4 |
5 | const inter = Inter({
6 | subsets: ["latin"],
7 | });
8 |
9 | export default function RootLayout({ children }: { children: React.ReactNode }) {
10 | return (
11 |
12 | {/*
13 | will contain the components returned by the nearest parent
14 | head.tsx. Find out more at https://beta.nextjs.org/docs/api-reference/file-conventions/head
15 | */}
16 |
17 |
18 |
19 | {" "}
20 |
31 |
41 |
49 | {children}
50 |
51 |
52 |
53 |
54 | );
55 | }
56 |
--------------------------------------------------------------------------------
/packages/react-databrowser/src/components/ui/scroll-area.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
3 |
4 | import { cn } from "@/lib/utils";
5 |
6 | const ScrollArea = React.forwardRef<
7 | React.ElementRef,
8 | React.ComponentPropsWithoutRef
9 | >(({ className, children, ...props }, ref) => (
10 |
11 | {children}
12 |
13 |
14 |
15 | ));
16 | ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
17 |
18 | const ScrollBar = React.forwardRef<
19 | React.ElementRef,
20 | React.ComponentPropsWithoutRef
21 | >(({ className, ...props }, ref) => (
22 |
32 |
35 |
36 | ));
37 | ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
38 |
39 | export { ScrollArea, ScrollBar };
40 |
--------------------------------------------------------------------------------
/examples/nextjs13/e2e/databrowser-set.test.ts:
--------------------------------------------------------------------------------
1 | import { test, expect } from "@playwright/test";
2 |
3 | test("should add data from cli then try to navigate on databrowser", async ({ page }) => {
4 | await page.goto("http://localhost:3000/");
5 |
6 | // Inputting the command to delete the set, to ensure the test starts clean
7 | await page.getByRole("textbox").click();
8 | await page.getByRole("textbox").fill("DEL really_long_set");
9 | await page.getByRole("textbox").press("Enter");
10 | await page.waitForTimeout(500); //TODO: Dirty hack to wait after delete. Should be fixed later.
11 |
12 | // Inputting the SADD command to add a set with 50 members
13 | await page
14 | .getByRole("textbox")
15 | .fill(`SADD really_long_set ${Array.from({ length: 50 }, (_, i) => `item${i + 1}`).join(" ")}`);
16 | await page.getByRole("textbox").press("Enter");
17 |
18 | await page.goto("http://localhost:3000/databrowser");
19 | await page.getByPlaceholder("Search").click();
20 | await page.getByPlaceholder("Search").fill("really");
21 | await page.getByRole("button", { name: "S really_long_set" }).click();
22 |
23 | // Expect the first item of the set to be visible
24 | await expect(page.getByText("item1", { exact: true })).toBeVisible();
25 |
26 | // Navigate through pages to find the last item
27 | for (let i = 0; i < 4; i++) {
28 | await page.getByTestId("datatable-next").click({ delay: 100 });
29 | }
30 | await expect(page.getByText("item50", { exact: true })).toBeVisible();
31 |
32 | // Navigate back to find the first item again
33 | for (let i = 0; i < 4; i++) {
34 | await page.getByTestId("datatable-prev").click({ delay: 100 });
35 | }
36 | await expect(page.getByText("item1", { exact: true })).toBeVisible();
37 | });
38 |
--------------------------------------------------------------------------------
/examples/nextjs13/e2e/databrowser-hash.test.ts:
--------------------------------------------------------------------------------
1 | import { test, expect } from "@playwright/test";
2 |
3 | test("should add data from cli then try to navigate on databrowser", async ({ page }) => {
4 | await page.goto("http://localhost:3000/");
5 |
6 | // Inputting the command to delete the hash, to ensure the test starts clean
7 | await page.getByRole("textbox").click();
8 | await page.getByRole("textbox").fill("DEL really_long_hash");
9 | await page.getByRole("textbox").press("Enter");
10 | await page.waitForTimeout(500); //TODO: Dirty hack to wait after delete. Should be fixed later.
11 |
12 | // Inputting the HSET command to add a hash with 50 fields
13 | await page
14 | .getByRole("textbox")
15 | .fill(`HSET really_long_hash ${Array.from({ length: 50 }, (_, i) => `field${i + 1} item${i + 1}`).join(" ")}`);
16 | await page.getByRole("textbox").press("Enter");
17 |
18 | await page.goto("http://localhost:3000/databrowser");
19 | await page.getByPlaceholder("Search").click();
20 | await page.getByPlaceholder("Search").fill("really");
21 | await page.getByRole("button", { name: "H really_long_hash" }).click();
22 |
23 | // Expect the first item of the hash to be visible
24 | await expect(page.getByText("item1", { exact: true })).toBeVisible();
25 |
26 | // Navigate through pages to find the last item
27 | for (let i = 0; i < 4; i++) {
28 | await page.getByTestId("datatable-next").click({ delay: 100 });
29 | }
30 | await expect(page.getByText("item50", { exact: true })).toBeVisible();
31 |
32 | // Navigate back to find the first item again
33 | for (let i = 0; i < 4; i++) {
34 | await page.getByTestId("datatable-prev").click({ delay: 100 });
35 | }
36 | await expect(page.getByText("item1", { exact: true })).toBeVisible();
37 | });
38 |
--------------------------------------------------------------------------------
/packages/react-databrowser/src/components/databrowser/components/sidebar/data-type-selector.tsx:
--------------------------------------------------------------------------------
1 | import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
2 | import { type RedisDataTypeUnion, RedisDataTypes } from "@/types";
3 |
4 | const RedisDataTypeMap = new Map(
5 | RedisDataTypes.map((type) => {
6 | switch (type) {
7 | case "zset":
8 | return [type, "ZSet"];
9 | case "json":
10 | return [type, "JSON"];
11 | default:
12 | return [type, type];
13 | }
14 | }),
15 | );
16 |
17 | type Props = {
18 | onDataTypeChange: (dataType?: RedisDataTypeUnion) => void;
19 | dataType?: RedisDataTypeUnion;
20 | };
21 |
22 | export function DataTypeSelector({ onDataTypeChange, dataType }: Props) {
23 | const handleValueChange = (data: string) => {
24 | onDataTypeChange(data as RedisDataTypeUnion);
25 | };
26 |
27 | return (
28 |
50 | );
51 | }
52 |
--------------------------------------------------------------------------------
/examples/nextjs13/e2e/databrowser-zset.test.ts:
--------------------------------------------------------------------------------
1 | import { test, expect } from "@playwright/test";
2 |
3 | test("should add data from cli then try to navigate on databrowser", async ({ page }) => {
4 | await page.goto("http://localhost:3000/");
5 |
6 | // Inputting the command to delete the zset, to ensure the test starts clean
7 | await page.getByRole("textbox").click();
8 | await page.getByRole("textbox").fill("DEL really_long_zset");
9 | await page.getByRole("textbox").press("Enter");
10 | await page.waitForTimeout(500); //TODO: Dirty hack to wait after delete. Should be fixed later.
11 |
12 | // Inputting the ZADD command to add a zset with 50 members
13 | await page
14 | .getByRole("textbox")
15 | .fill(`ZADD really_long_zset ${Array.from({ length: 50 }, (_, i) => `${i + 1} item${i + 1}`).join(" ")}`);
16 | await page.getByRole("textbox").press("Enter");
17 | await page.waitForSelector(".upstash-cli-result");
18 |
19 | await page.goto("http://localhost:3000/databrowser");
20 | await page.getByPlaceholder("Search").click();
21 | await page.getByPlaceholder("Search").fill("really");
22 | await page.getByRole("button", { name: "z really_long_zset" }).click();
23 |
24 | // Expect the first item of the zset to be visible
25 | await expect(page.getByText("item1", { exact: true })).toBeVisible();
26 |
27 | // Navigate through pages to find the last item
28 | for (let i = 0; i < 4; i++) {
29 | await page.getByTestId("datatable-next").click({ delay: 100 });
30 | }
31 |
32 | await expect(page.getByText("item50", { exact: true })).toBeVisible();
33 |
34 | // Navigate back to find the first item again
35 | for (let i = 0; i < 4; i++) {
36 | await page.getByTestId("datatable-prev").click({ delay: 100 });
37 | }
38 | await expect(page.getByText("item1", { exact: true })).toBeVisible();
39 | });
40 |
--------------------------------------------------------------------------------
/examples/nextjs13/README.md:
--------------------------------------------------------------------------------
1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
2 |
3 | ## Getting Started
4 |
5 | First, run the development server:
6 |
7 | ```bash
8 | npm run dev
9 | # or
10 | yarn dev
11 | # or
12 | pnpm dev
13 | ```
14 |
15 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
16 |
17 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
18 |
19 | [API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`.
20 |
21 | The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages.
22 |
23 | This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
24 |
25 | ## Learn More
26 |
27 | To learn more about Next.js, take a look at the following resources:
28 |
29 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
30 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
31 |
32 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
33 |
34 | ## Deploy on Vercel
35 |
36 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
37 |
38 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
39 |
--------------------------------------------------------------------------------
/examples/nextjs13/app/custom-commands/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { RedisCli } from "@upstash/react-cli";
3 |
4 | export default function Home() {
5 | const upstashRedisRestUrl = process.env.NEXT_PUBLIC_UPSTASH_REDIS_REST_URL;
6 | if (!upstashRedisRestUrl) {
7 | return UPSTASH_REDIS_REST_URL not set
;
8 | }
9 | const upstashRedisRestToken = process.env.NEXT_PUBLIC_UPSTASH_REDIS_REST_TOKEN;
10 | if (!upstashRedisRestToken) {
11 | return UPSTASH_REDIS_REST_TOKEN not set
;
12 | }
13 |
14 | return (
15 |
26 |
36 |
{
42 | ctx.addCommand({
43 | command: "hello",
44 | result: "world",
45 | });
46 | },
47 | complex: async (ctx) => {
48 | const res = await fetch("https://jsonplaceholder.typicode.com/todos/1").then((response) =>
49 | response.text(),
50 | );
51 |
52 | ctx.addCommand({
53 | command: "complex",
54 | result: {res}
,
55 | });
56 | },
57 | }}
58 | />
59 |
60 |
61 | );
62 | }
63 |
--------------------------------------------------------------------------------
/packages/react-databrowser/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@upstash/react-databrowser",
3 | "version": "0.3.20",
4 | "main": "./dist/index.js",
5 | "types": "./dist/index.d.ts",
6 | "license": "MIT",
7 | "private": false,
8 | "publishConfig": {
9 | "access": "public"
10 | },
11 | "bugs": {
12 | "url": "https://github.com/upstash/react-ui/issues"
13 | },
14 | "homepage": "https://github.com/upstash/react-ui#readme",
15 | "files": [
16 | "./dist/**"
17 | ],
18 | "author": "Oguzhan Olguncu ",
19 | "scripts": {
20 | "build": "tsc && tsup",
21 | "dev": "vite",
22 | "lint": "tsc"
23 | },
24 | "devDependencies": {
25 | "@types/node": "^18.15.11",
26 | "@types/react": "^18.0.37",
27 | "@types/react-dom": "^18.0.11",
28 | "@vitejs/plugin-react": "^4.1.0",
29 | "autoprefixer": "^10.4.14",
30 | "class-variance-authority": "^0.7.0",
31 | "clsx": "^2.0.0",
32 | "postcss": "^8.4.31",
33 | "react": "^18.2.0",
34 | "react-dom": "^18.2.0",
35 | "tailwind-merge": "^1.14.0",
36 | "tailwindcss": "^3.3.1",
37 | "tailwindcss-animate": "^1.0.7",
38 | "tsup": "^6.7.0",
39 | "typescript": "^5.0.4",
40 | "vite": "^4.4.10",
41 | "vite-tsconfig-paths": "^4.2.1"
42 | },
43 | "peerDependencies": {
44 | "react": "^18.2.0",
45 | "react-dom": "^18.2.0"
46 | },
47 | "dependencies": {
48 | "@monaco-editor/react": "^4.6.0",
49 | "@radix-ui/react-alert-dialog": "^1.0.5",
50 | "@radix-ui/react-dialog": "^1.0.5",
51 | "@radix-ui/react-icons": "1.3.0",
52 | "@radix-ui/react-label": "^2.0.2",
53 | "@radix-ui/react-popover": "^1.0.7",
54 | "@radix-ui/react-scroll-area": "^1.0.3",
55 | "@radix-ui/react-select": "^2.0.0",
56 | "@radix-ui/react-separator": "^1.0.3",
57 | "@radix-ui/react-slot": "^1.0.2",
58 | "@radix-ui/react-toast": "^1.1.5",
59 | "@radix-ui/react-tooltip": "^1.0.7",
60 | "@tanstack/react-query": "^5.32.0",
61 | "@upstash/redis": "^1.31.6"
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/packages/react-databrowser/src/components/databrowser/index.tsx:
--------------------------------------------------------------------------------
1 | import "@/globals.css";
2 | import { queryClient } from "@/lib/clients";
3 | import { type DatabrowserProps, DatabrowserProvider } from "@/store";
4 | import type { RedisDataTypeUnion } from "@/types";
5 | import { useMemo, useState } from "react";
6 | import { QueryClientProvider } from "@tanstack/react-query";
7 | import { Toaster } from "../ui/toaster";
8 | import { DataDisplayContainer } from "./components/data-display-container";
9 | import { Sidebar } from "./components/sidebar";
10 |
11 | export const Databrowser = ({ token, url }: DatabrowserProps) => {
12 | const [selectedDataKey, setSelectedDataKey] = useState<[string, RedisDataTypeUnion] | undefined>();
13 | const [dataFetchTimestamp, setDataFetchTimestamp] = useState(Date.now());
14 |
15 | const refetchData = () => {
16 | setDataFetchTimestamp(Date.now());
17 | };
18 |
19 | const handleDataKeySelect = (dataKey?: [string, RedisDataTypeUnion]) => {
20 | setSelectedDataKey(dataKey);
21 | refetchData();
22 | };
23 |
24 | const databrowserCredentials = useMemo(() => ({ token, url }), [token, url]);
25 |
26 | return (
27 |
28 |
29 |
30 |
31 |
36 |
41 |
42 |
43 |
44 |
45 |
46 | );
47 | };
48 |
--------------------------------------------------------------------------------
/examples/nextjs13/e2e/databrowser-json.test.ts:
--------------------------------------------------------------------------------
1 | import { test, expect } from "@playwright/test";
2 |
3 | test("should add JSON data from cli then try to navigate on databrowser", async ({ page }) => {
4 | await page.goto("http://localhost:3000/");
5 |
6 | // Inputting the command to delete the key, to ensure the test starts clean
7 | await page.getByRole("textbox").click();
8 | await page.getByRole("textbox").fill("DEL my_json_object");
9 | await page.getByRole("textbox").press("Enter");
10 | await page.waitForTimeout(500); //TODO: Dirty hack to wait after delete. Should be fixed later.
11 | // Inputting the JSON.SET command to set a JSON object
12 | const jsonObject = {
13 | name: "JohnDoe",
14 | age: 30,
15 | cars: ["Ford", "BMW", "Fiat"],
16 | id: 11,
17 | title: "perfume Oil",
18 | description: "Mega Discount, Impression of A...",
19 | price: 13,
20 | discountPercentage: 8.4,
21 | rating: 4.26,
22 | stock: 65,
23 | brand: "Impression of Acqua Di Gio",
24 | category: "fragrances",
25 | thumbnail: "https://i.dummyjson.com/data/products/11/thumbnail.jpg",
26 | images: [
27 | "https://i.dummyjson.com/data/products/11/1.jpg",
28 | "https://i.dummyjson.com/data/products/11/2.jpg",
29 | "https://i.dummyjson.com/data/products/11/3.jpg",
30 | "https://i.dummyjson.com/data/products/11/thumbnail.jpg",
31 | ],
32 | };
33 | await page.getByRole("textbox").fill(`JSON.SET my_json_object $ '${JSON.stringify(jsonObject)}'`);
34 | await page.getByRole("textbox").press("Enter");
35 |
36 | await page.goto("http://localhost:3000/databrowser");
37 | await page.getByPlaceholder("Search").click();
38 | await page.getByPlaceholder("Search").fill("my_json_object");
39 | await page.getByRole("button", { name: "j my_json_object" }).click();
40 |
41 | // Expect some data from the JSON object to be visible on the screen
42 | await expect(page.locator(':text-matches("JohnDoe")')).toBeVisible();
43 | await expect(page.locator(':text-matches("30")')).toBeVisible();
44 | await expect(page.locator(':text-matches("Ford")')).toBeVisible();
45 | });
46 |
--------------------------------------------------------------------------------
/packages/react-databrowser/src/components/databrowser/components/sidebar/data-key-buttons.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@/components/ui/button";
2 | import { RedisTypeTag } from "@/components/databrowser/type-tag";
3 | import type { RedisDataTypeUnion } from "@/types";
4 | import { Fragment } from "react";
5 |
6 | type Props = {
7 | dataKeys: [string, RedisDataTypeUnion][];
8 | selectedDataKey?: string;
9 | onDataKeyChange: (dataKey?: [string, RedisDataTypeUnion]) => void;
10 | };
11 | export const DataKeyButtons = ({ dataKeys, selectedDataKey, onDataKeyChange }: Props) => (
12 | <>
13 | {dataKeys.map(([dataKey, dataType], index) => {
14 | const isSelected = selectedDataKey === dataKey;
15 | return (
16 |
17 | {!isSelected && selectedDataKey !== dataKeys[index - 1]?.[0] && (
18 |
19 | )}
20 |
44 |
45 | );
46 | })}
47 | >
48 | );
49 |
--------------------------------------------------------------------------------
/packages/react-cli/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # @upstash/react-cli
2 |
3 | ## 1.0.13
4 |
5 | ### Patch Changes
6 |
7 | - f1fc2f8: fix typo
8 |
9 | ## 1.0.12
10 |
11 | ### Patch Changes
12 |
13 | - 3bed065: added new help text to cli
14 |
15 | ## 1.0.11
16 |
17 | ### Minor Changes
18 |
19 | - 4d8d0a6: Disabling of sourcemap and allowing minimize in cli
20 |
21 | ## 1.0.10
22 |
23 | ### Patch Changes
24 |
25 | - ff6d5ba: Bumped postcss version to get rid of dependabot warning
26 |
27 | ## 1.0.9
28 |
29 | ### Patch Changes
30 |
31 | - 3dcd8d7: Add Vite to enable standalone mode in the CLI
32 |
33 | ## 1.0.8
34 |
35 | ### Patch Changes
36 |
37 | - c51eb3a: Applied formatter and linter
38 |
39 | ## 1.0.7
40 |
41 | ### Patch Changes
42 |
43 | - display error
44 |
45 | ## 1.0.6
46 |
47 | ### Patch Changes
48 |
49 | - fix stdin width
50 |
51 | ## 1.0.5
52 |
53 | ### Patch Changes
54 |
55 | - x-overflow
56 |
57 | ## 1.0.4
58 |
59 | ### Patch Changes
60 |
61 | - x-overflow
62 |
63 | ## 1.0.3
64 |
65 | ### Patch Changes
66 |
67 | - remove x-overflow
68 |
69 | ## 1.0.2
70 |
71 | ### Patch Changes
72 |
73 | - remove padding
74 |
75 | ## 1.0.1
76 |
77 | ### Patch Changes
78 |
79 | - fix bg color
80 |
81 | ## 1.0.0
82 |
83 | ### Major Changes
84 |
85 | - Rewrite in vanilla js
86 |
87 | ## 0.8.0
88 |
89 | ### Minor Changes
90 |
91 | - Add optional "welcome" parameter
92 |
93 | ## 0.7.3
94 |
95 | ### Patch Changes
96 |
97 | - add scrollbar
98 |
99 | ## 0.7.2
100 |
101 | ### Patch Changes
102 |
103 | - fix scrollbar
104 |
105 | ## 0.7.1
106 |
107 | ### Patch Changes
108 |
109 | - add tailwind example
110 |
111 | ## 0.7.0
112 |
113 | ### Minor Changes
114 |
115 | - Add className
116 |
117 | ## 0.6.0
118 |
119 | ### Minor Changes
120 |
121 | - fix: line breaks
122 |
123 | ## 0.5.0
124 |
125 | ### Minor Changes
126 |
127 | - Fixed scrolling #2
128 |
129 | ## 0.4.0
130 |
131 | ### Minor Changes
132 |
133 | - Fix scrolling
134 |
135 | ## 0.3.0
136 |
137 | ### Minor Changes
138 |
139 | - Package properly with jsx=react
140 |
141 | ## 0.2.0
142 |
143 | ### Minor Changes
144 |
145 | - First release
146 |
--------------------------------------------------------------------------------
/packages/react-databrowser/src/components/databrowser/components/data-display-container/data-delete.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@/components/ui/button";
2 | import { useToast } from "@/components/ui/use-toast";
3 | import type { RedisDataTypeUnion } from "@/types";
4 | import { useDeleteKey } from "../../hooks/useDeleteKey";
5 | import { DeleteAlertDialog } from "./delete-alert-dialog";
6 |
7 | type Props = {
8 | selectedDataKey: string;
9 | onDataKeyChange: (dataKey?: [string, RedisDataTypeUnion]) => void;
10 | };
11 |
12 | export const DataDelete = ({ onDataKeyChange, selectedDataKey }: Props) => {
13 | const { toast } = useToast();
14 | const deleteKey = useDeleteKey();
15 |
16 | const handleDeleteKey = async () => {
17 | try {
18 | const result = await deleteKey.mutateAsync(selectedDataKey);
19 | if (result) {
20 | onDataKeyChange(undefined);
21 | }
22 | } catch (error) {
23 | toast({
24 | variant: "destructive",
25 | title: "Uh oh! Something went wrong.",
26 | description: (error as Error).message,
27 | });
28 | }
29 | };
30 |
31 | return (
32 |
33 |
34 |
46 |
47 |
48 | );
49 | };
50 |
--------------------------------------------------------------------------------
/packages/react-databrowser/README.md:
--------------------------------------------------------------------------------
1 | # @upstash/react-databrowser
2 |
3 |
4 |
Databrowser for Upstash Redis
5 |
6 |
7 |
8 | Explore the demo application
9 |
10 |
11 | ---
12 |
13 | ## Introduction
14 |
15 | `@upstash/react-databrowser` is a React component that provides a UI for browsing data in your Upstash Redis instances. It’s easy to set up and integrate into your React applications. This guide will help you get started with the installation and basic usage.
16 |
17 | ## Table of Contents
18 |
19 | - [Install](#1-install)
20 | - [Configuration](#2-configuration)
21 | - [Usage](#3-usage)
22 |
23 | ## 1. Install
24 |
25 | Install the databrowser component via npm:
26 |
27 | ```sh-session
28 | $ npm install @upstash/react-databrowser
29 | ```
30 |
31 | ## 2. Configuration
32 |
33 | ### Environment Variables
34 | Configure your Upstash Redis REST URL and token as environment variables:
35 |
36 | ```sh-session
37 | NEXT_PUBLIC_UPSTASH_REDIS_REST_URL=YOUR_REDIS_REST_URL
38 | NEXT_PUBLIC_UPSTASH_REDIS_REST_TOKEN=YOUR_REDIS_REST_TOKEN
39 | ```
40 | ## 3. Usage
41 | ### Creating the Data Browser Component
42 |
43 | In your React application, create a new component that will utilize @upstash/react-databrowser.
44 |
45 | Here's a basic example of how to use the component:
46 |
47 | ```tsx
48 | // /app/components/DatabrowserDemo.tsx
49 |
50 | import { Databrowser } from "@upstash/react-databrowser";
51 | import "@upstash/react-databrowser/dist/index.css";
52 |
53 | export default function DatabrowserDemo() {
54 | const redisUrl = process.env.NEXT_PUBLIC_UPSTASH_REDIS_REST_URL;
55 | const redisToken = process.env.NEXT_PUBLIC_UPSTASH_REDIS_REST_TOKEN;
56 |
57 | return (
58 |
59 |
60 |
61 |
62 |
63 | );
64 | }
65 |
66 | const mainStyle = {
67 | height: "100vh",
68 | width: "100vw",
69 | display: "flex",
70 | alignItems: "center",
71 | justifyContent: "center",
72 | flexDirection: "column",
73 | background: "rgb(250,250,250)",
74 | };
75 |
76 | const divStyle = {
77 | height: "100%",
78 | width: "100%",
79 | maxHeight: "45rem",
80 | maxWidth: "64rem",
81 | borderRadius: "0.5rem",
82 | overflow: "hidden",
83 | };
84 |
85 | ```
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | > [!WARNING]
2 | > **This project is archieved.**
3 | >
4 | > Two projects in this repository are available and being developed under [upstash/react-redis-browser](https://github.com/upstash/react-redis-browser/) and [upstash/react-redis-cli](https://github.com/upstash/react-redis-cli/) repositories.
5 |
6 |
7 |
8 |
React UI
9 | Various react components from our console
10 |
11 |
12 |
15 |
16 |
17 | > [!NOTE]
18 | > **This project is in the Experimental Stage.**
19 | >
20 | > We declare this project experimental to set clear expectations for your usage. There could be known or unknown bugs, the API could evolve, or the project could be discontinued if it does not find community adoption. While we cannot provide professional support for experimental projects, we’d be happy to hear from you if you see value in this project!
21 |
22 | ## Components
23 |
24 | - [Redis CLI](https://github.com/upstash/react-ui/blob/main/packages/react-cli/README.md)
25 | - [Redis Databrowser](https://github.com/upstash/react-ui/blob/main/packages/react-databrowser/README.md)
26 |
27 |
28 |
29 | ## Development
30 |
31 | This monorepo is managed by turborepo and uses `pnpm` for dependency management.
32 |
33 | #### Install dependencies
34 |
35 | ```bash
36 | pnpm install
37 | ```
38 |
39 | #### Build
40 |
41 | ```bash
42 | pnpm build
43 | ```
44 |
45 | #### Run Test
46 |
47 | Set the `NEXT_PUBLIC_UPSTASH_REDIS_REST_URL` and `NEXT_PUBLIC_UPSTASH_REDIS_REST_TOKEN` environment
48 | variables by creating a `.env` file under `examples/nextjs13`.
49 |
50 | ```bash
51 | cd examples/nextjs
52 | npx playwright install
53 | pnpm test
54 | ```
55 |
56 | ## Release
57 |
58 | 1. Run `pnpm changeset`
59 | This will prompt you to select which packages have changed. It will also create a changeset file in the `.changeset` directory.
60 | 2. Run `pnpm changeset version`
61 | This will bump the versions of the packages previously specified with pnpm changeset (and any dependents of those) and update the changelog files.
62 | 3. Run `pnpm install`
63 | This will update the lockfile and rebuild packages.
64 | 4. Commit the changes
65 | 5. Run `pnpm publish -r`
66 | This command will publish all packages that have bumped versions not yet present in the registry.
67 |
--------------------------------------------------------------------------------
/packages/react-databrowser/src/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Slot } from "@radix-ui/react-slot";
3 | import { cva, type VariantProps } from "class-variance-authority";
4 |
5 | import { cn } from "@/lib/utils";
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-white transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-950 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 dark:ring-offset-neutral-950 dark:focus-visible:ring-neutral-300",
9 | {
10 | variants: {
11 | variant: {
12 | default:
13 | "bg-[#FFFFFF] text-black hover:bg-[#FFFFFF]/70 dark:bg-black dark:text-[#FFFFFF] dark:hover:bg-black/10",
14 | destructive:
15 | "bg-red-500 text-neutral-50 hover:bg-red-500/90 dark:bg-red-900 dark:text-neutral-50 dark:hover:bg-red-900/90",
16 | outline:
17 | "border border-neutral-200 bg-white hover:bg-neutral-100 hover:text-neutral-900 dark:border-neutral-800 dark:bg-neutral-950 dark:hover:bg-neutral-800 dark:hover:text-neutral-50",
18 | secondary:
19 | "bg-neutral-100 text-neutral-900 hover:bg-neutral-100/80 dark:bg-neutral-800 dark:text-neutral-50 dark:hover:bg-neutral-800/80",
20 | ghost: "hover:bg-[#0000000A] dark:hover:bg-neutral-800 dark:hover:text-neutral-50",
21 | link: "text-neutral-900 underline-offset-4 hover:underline dark:text-neutral-50",
22 | },
23 | size: {
24 | default: "h-10 px-4 py-2",
25 | sm: "h-9 rounded-md px-3",
26 | lg: "h-11 rounded-md px-8",
27 | icon: "h-10 w-10",
28 | "icon-sm": "h-7 w-7",
29 | "icon-xs": "h-5 w-5",
30 | },
31 | },
32 | defaultVariants: {
33 | variant: "default",
34 | size: "default",
35 | },
36 | },
37 | );
38 |
39 | export interface ButtonProps
40 | extends React.ButtonHTMLAttributes,
41 | VariantProps {
42 | asChild?: boolean;
43 | }
44 |
45 | const Button = React.forwardRef(
46 | ({ className, variant, size, asChild = false, ...props }, ref) => {
47 | const Comp = asChild ? Slot : "button";
48 | return ;
49 | },
50 | );
51 | Button.displayName = "Button";
52 |
53 | export { Button, buttonVariants };
54 |
--------------------------------------------------------------------------------
/packages/react-databrowser/src/components/databrowser/copy-to-clipboard-button.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@/components/ui/button";
2 | import { CheckIcon } from "@radix-ui/react-icons";
3 | import { useState } from "react";
4 |
5 | interface Props extends React.HTMLAttributes {
6 | onCopy: () => void;
7 | sizeVariant?: "icon-sm" | "icon-xs";
8 | svgSize?: { w: number; h: number };
9 | variant?: "outline" | "default" | "ghost";
10 | }
11 |
12 | export function CopyToClipboardButton({
13 | onCopy,
14 | sizeVariant = "icon-sm",
15 | variant = "outline",
16 | svgSize,
17 | className,
18 | }: Props) {
19 | const [copied, setCopied] = useState(false);
20 |
21 | const handleCopy = () => {
22 | setCopied(true);
23 | onCopy();
24 | setTimeout(() => {
25 | setCopied(false);
26 | }, 1500);
27 | };
28 |
29 | return (
30 |
52 | );
53 | }
54 |
55 | export const handleCopyClick = async (textToCopy: string) => {
56 | try {
57 | await navigator.clipboard.writeText(textToCopy);
58 | } catch (err) {
59 | console.error("Failed to copy text: ", err);
60 | }
61 | };
62 |
--------------------------------------------------------------------------------
/packages/react-databrowser/src/components/databrowser/hooks/useFetchSingleDataByKey/utils.ts:
--------------------------------------------------------------------------------
1 | import { partition } from "@/lib/utils";
2 |
3 | export const INITIAL_CURSOR_NUM = 0;
4 | export const DATA_PER_PAGE = 10;
5 | /**
6 | * Transforms a data object into a specific desired array format.
7 | *
8 | * Converts an input object like:
9 | *
10 | * ```
11 | * {
12 | * "1696942597667-0": {item: 1, item1: 2, item2: 3},
13 | * "1696942598807-0": {item: 2}
14 | * }
15 | * ```
16 | *
17 | * Into an output format like:
18 | *
19 | * ```
20 | * [
21 | * {
22 | * value: "1696942597667-0",
23 | * content: ["item 1", "item1 2", "item2 3"]
24 | * },
25 | * {
26 | * value: "1696942598807-0",
27 | * content: ["item 2"]
28 | * }
29 | * ]
30 | * ```
31 | */
32 | export function transformStream(result: Record>) {
33 | return Object.entries(result).map(([key, values]) => ({
34 | content: Object.entries(values)
35 | .map(([field, value]) => `${field}:${value}`)
36 | .join("\n"),
37 | value: key,
38 | }));
39 | }
40 |
41 | export type ContentValue = {
42 | content: string | number;
43 | value: string | number | null;
44 | };
45 |
46 | export function transformArray(inputArray: (string | number)[]): ContentValue[] {
47 | if (inputArray.length % 2 !== 0) {
48 | throw new Error("The input array length must be even.");
49 | }
50 |
51 | return inputArray.reduce((acc, curr, idx, src) => {
52 | if (idx % 2 === 0) {
53 | acc.push({ content: toJsonStringifiable(curr, 0), value: src[idx + 1] });
54 | }
55 | return acc;
56 | }, []);
57 | }
58 |
59 | export function transformHash(inputArray: (string | number)[]): ContentValue[] {
60 | if (inputArray.length % 2 !== 0) {
61 | throw new Error("The input array length must be even.");
62 | }
63 | const zippedHash = partition(inputArray, 2);
64 | return zippedHash.map((item) => ({
65 | value: toJsonStringifiable(item[0], 0),
66 | content: toJsonStringifiable(item[1], 0),
67 | }));
68 | }
69 |
70 | export const toJsonStringifiable = (content: unknown, spacing = 2): string => {
71 | try {
72 | if (typeof content === "string") {
73 | try {
74 | const parsed = JSON.parse(content);
75 | return JSON.stringify(parsed, null, spacing);
76 | } catch {
77 | return content;
78 | }
79 | }
80 |
81 | return JSON.stringify(content, null, spacing);
82 | } catch (error) {
83 | console.error("Error converting to JSON:", error);
84 | throw error;
85 | }
86 | };
87 |
--------------------------------------------------------------------------------
/examples/nextjs13/e2e/databrowser-edit-json.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, test } from "@playwright/test";
2 | import { generateRandomString } from "./utils";
3 |
4 | test("should scroll to bottom and update the line at the bottom", async ({ page }) => {
5 | await page.goto("http://localhost:3000/");
6 |
7 | // Inputting the command to delete the key, to ensure the test starts clean
8 | await page.getByRole("textbox").click();
9 | await page.getByRole("textbox").fill("DEL my_json_object");
10 | await page.getByRole("textbox").press("Enter");
11 | await page.waitForTimeout(500); //TODO: Dirty hack to wait after delete. Should be fixed later.
12 | // Inputting the JSON.SET command to set a JSON object
13 | const jsonObject = {
14 | name: "John Doe",
15 | age: 30,
16 | cars: ["Ford", "BMW", "Fiat"],
17 | id: 11,
18 | title: "perfume Oil",
19 | description: "Mega Discount, Impression of A...",
20 | price: 13,
21 | discountPercentage: 8.4,
22 | rating: 4.26,
23 | stock: 65,
24 | brand: "Impression of Acqua Di Gio",
25 | category: "fragrances",
26 | thumbnail: "https://i.dummyjson.com/data/products/11/thumbnail.jpg",
27 | images: [
28 | "https://i.dummyjson.com/data/products/11/1.jpg",
29 | "https://i.dummyjson.com/data/products/11/2.jpg",
30 | "https://i.dummyjson.com/data/products/11/3.jpg",
31 | "https://i.dummyjson.com/data/products/11/thumbnail.jpg",
32 | ],
33 | };
34 | await page.getByRole("textbox").fill(`JSON.SET my_json_object $ '${JSON.stringify(jsonObject)}'`);
35 | await page.getByRole("textbox").press("Enter");
36 |
37 | const randomString = generateRandomString();
38 | await page.goto("http://localhost:3000/databrowser");
39 |
40 | await page.getByRole("button", { name: "j my_json_object" }).click();
41 | await page.getByTestId("edit-items-in-place").click();
42 |
43 | await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
44 |
45 | await page
46 | .getByLabel("Editor content;Press Alt+F1 for Accessibility Options.")
47 | .fill(
48 | `{\n "age": 30,\n "brand": "${randomString}",\n "cars": [\n "BMW",\n "Fiat",\n "Ford"\n ],\n "category": "fragrances",\n "description": "Mega Discount, Impression of A...",\n "discountPercentage": 8.4,\n "id": 11,\n "images": [\n "https://i.dummyjson.com/data/products/11/1.jpg",\n "https://i.dummyjson.com/data/products/11/2.jpg",\n "https://i.dummyjson.com/data/products/11/3.jpg",\n "https://i.dummyjson.com/data/products/11/thumbnail.jpg"\n ],\n "name": "John Doe",\n "price": 13,\n "rating": 4.26,\n "stock": 65,\n "thumbnail": "${randomString}",\n "title": "perfume Oil"\n}`,
49 | );
50 | await page.getByTestId("save-items").click();
51 | await expect(page.getByText(randomString)).toHaveCount(2);
52 | });
53 |
--------------------------------------------------------------------------------
/examples/nextjs13/e2e/databrowser-test-reset-e2e.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, test } from "@playwright/test";
2 |
3 | test("should test reset button with pagination, search and data type selection", async ({ page }) => {
4 | // This is a long test, so we increase the timeout
5 | test.setTimeout(20 * 1000);
6 |
7 | //FLUSH DB
8 | await page.goto("http://localhost:3000/");
9 |
10 | // Flushes db
11 | await page.getByRole("textbox").click();
12 | await page.getByRole("textbox").fill("flushdb");
13 | await page.getByRole("textbox").press("Enter");
14 | await page.waitForTimeout(500); // TODO: Consider using a more reliable wait condition
15 | //ADD HASH VALUE FIRST
16 | await page
17 | .getByRole("textbox")
18 | .fill(`HSET really_long_hash ${Array.from({ length: 2 }, (_, i) => `field${i + 1} item${i + 1}`).join(" ")}`);
19 | await page.getByRole("textbox").press("Enter");
20 | await page.waitForTimeout(500); // TODO: Consider using a more reliable wait condition
21 |
22 | //ADD STRING VALUE
23 | const myString = "my_string";
24 | await page.getByRole("textbox").fill(`SET my_string "${myString}"`);
25 | await page.getByRole("textbox").press("Enter");
26 | await page.waitForTimeout(500); // TODO: Consider using a more reliable wait condition
27 | //Add some more data
28 | const msetCommand = Array.from({ length: 50 }, (_, i) => `string${i + 1} "This is string ${i + 1}"`).join(" ");
29 | await page.getByRole("textbox").fill(`MSET ${msetCommand}`);
30 | await page.getByRole("textbox").press("Enter");
31 | await page.waitForTimeout(500); // TODO: Consider using a more reliable wait condition
32 |
33 | await page.goto("http://localhost:3000/databrowser");
34 | await page.getByPlaceholder("Search").click();
35 | await page.getByPlaceholder("Search").fill("*long");
36 | await page.getByText("Data on a break").click();
37 | await page.getByPlaceholder("Search").click();
38 | await page.getByPlaceholder("Search").fill("*long*");
39 | await page.getByRole("button", { name: "h really_long_hash" }).click();
40 | await page.getByPlaceholder("Search").clear();
41 | await page.getByRole("button", { name: `s ${myString}` }).click();
42 | await page.getByTestId("sidebar-next").click({
43 | clickCount: 3,
44 | });
45 | await page.getByTestId("reset").click();
46 | await page.getByRole("button", { name: `s ${myString}` }).click();
47 | await page.getByRole("combobox").click();
48 | await page.getByLabel("Hash").click();
49 | await page.getByRole("button", { name: "h really_long_hash" }).click();
50 |
51 | await page.getByPlaceholder("Search").clear();
52 | await page.locator("body").click();
53 |
54 | await page.getByRole("combobox").click();
55 | await page.getByLabel("All Types").click();
56 |
57 | await expect(page.getByRole("button", { name: `s ${myString}` })).toBeVisible();
58 | });
59 |
--------------------------------------------------------------------------------
/packages/react-databrowser/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from "clsx";
2 | import { twMerge } from "tailwind-merge";
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs));
6 | }
7 |
8 | export function zip(array1: T[], array2: U[]): Array<[T, U]> {
9 | const length = Math.min(array1.length, array2.length);
10 | const result: Array<[T, U]> = [];
11 |
12 | for (let i = 0; i < length; i++) {
13 | result.push([array1[i], array2[i]]);
14 | }
15 |
16 | return result;
17 | }
18 |
19 | export function partition(arr: T[], size: number): T[][] {
20 | const result: T[][] = [];
21 |
22 | for (let i = 0; i < arr.length; i += size) {
23 | result.push(arr.slice(i, i + size));
24 | }
25 |
26 | return result;
27 | }
28 |
29 | type ColorOptions = {
30 | keyColor?: string;
31 | numberColor?: string;
32 | stringColor?: string;
33 | trueColor?: string;
34 | falseColor?: string;
35 | nullColor?: string;
36 | };
37 |
38 | const defaultColors: ColorOptions = {
39 | keyColor: "#B58900",
40 | numberColor: "#1A01CC",
41 | stringColor: "#2AA198",
42 | trueColor: "#1A01CC",
43 | falseColor: "#1A01CC",
44 | nullColor: "#1A01CC",
45 | };
46 |
47 | type ColorKeys = keyof ColorOptions;
48 | type ColorParts = ColorKeys extends `${infer Part}Color` ? Part : never;
49 |
50 | const entityMap: { [key: string]: string } = {
51 | "&": "&",
52 | "<": "<",
53 | ">": ">",
54 | '"': """,
55 | "'": "'",
56 | "`": "`",
57 | "=": "=",
58 | };
59 |
60 | function escapeHtml(html: string): string {
61 | return String(html).replace(/[&<>"'`=]/g, (s) => entityMap[s] || s);
62 | }
63 |
64 | export default function formatHighlight(json: unknown, colorOptions: ColorOptions = {}): string {
65 | let jsonStr: string;
66 | if (typeof json === "string") {
67 | jsonStr = json;
68 | } else {
69 | jsonStr = JSON.stringify(json, null, 2) ?? ""; // default to empty string if stringify returns undefined
70 | }
71 |
72 | const colors: ColorOptions = { ...defaultColors, ...colorOptions };
73 |
74 | return jsonStr.replace(
75 | /("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+]?\d+)?)/g,
76 | (match) => {
77 | let updatedMatch = match;
78 |
79 | let cls: ColorParts = "number"; // default to 'number'
80 | if (/^"/.test(match)) {
81 | if (/:$/.test(match)) {
82 | cls = "key";
83 | } else {
84 | cls = "string";
85 | updatedMatch = `"${escapeHtml(match.slice(1, -1))}"`;
86 | }
87 | } else if (/true/.test(updatedMatch)) {
88 | cls = "true";
89 | } else if (/false/.test(updatedMatch)) {
90 | cls = "false";
91 | } else if (/null/.test(updatedMatch)) {
92 | cls = "null";
93 | }
94 | return `${updatedMatch}`;
95 | },
96 | );
97 | }
98 |
--------------------------------------------------------------------------------
/packages/react-databrowser/src/lib/clients.ts:
--------------------------------------------------------------------------------
1 | import type { DatabrowserProps } from "@/store";
2 | import { Redis } from "@upstash/redis";
3 | import { QueryCache, QueryClient } from "@tanstack/react-query";
4 | import { toast } from "@/components/ui/use-toast";
5 |
6 | export const redisClient = (databrowser?: DatabrowserProps) => {
7 | const token = databrowser?.token || process.env.NEXT_PUBLIC_UPSTASH_REDIS_REST_TOKEN;
8 | const url = databrowser?.url || process.env.NEXT_PUBLIC_UPSTASH_REDIS_REST_URL;
9 |
10 | if (!url) {
11 | throw new Error("Redis URL is missing!");
12 | }
13 | if (!token) {
14 | throw new Error("Redis TOKEN is missing!");
15 | }
16 |
17 | const redis = new Redis({
18 | url,
19 | token,
20 | enableAutoPipelining: true,
21 | automaticDeserialization: false,
22 | keepAlive: false,
23 | });
24 |
25 | return redis;
26 | };
27 |
28 | /**
29 | * QueryClient Configuration.
30 | *
31 | * @summary
32 | * This configuration is mainly set to refetch data when the window gains focus and to keep the data from becoming stale for 2 minutes. However, there is a potential edge case where if a user, without changing focus, adds data from CLI and comes back, they might see the stale data for up to 2 minutes.
33 | *
34 | * @example
35 | * To reproduce the edge case:
36 | * 1. Divide your screen into two parts.
37 | * 2. Add data from the CLI on one side.
38 | * 3. Observe the application on the other side.
39 | *
40 | * This scenario can cause the data to be stale since switching to and from the CLI should ideally remount the component and hence the entire QueryProvider too, triggering a refetch.
41 | *
42 | * @todo
43 | * 1. Monitor if this edge case is encountered by users.
44 | * 2. If reported, consider increasing the staleTime to 3-4 minutes and refetching time to 1.5-2 minutes.
45 | * 3. Reassess whether retries are needed in this configuration, as the SDK already has retry mechanisms.
46 | *
47 | * @defaultOptions
48 | * - staleTime: 120000 ms (2 minutes) (Potential adjustment to 3-4 minutes if edge case reported.)
49 | * - refetchOnWindowFocus: true (Kept true to ensure data is not stale when user switches back to this window.)
50 | */
51 | export const queryClient = new QueryClient({
52 | defaultOptions: {
53 | queries: {
54 | refetchOnWindowFocus: true,
55 | retry: false,
56 | },
57 | },
58 | queryCache: new QueryCache({
59 | onError: (error) => {
60 | if (error.name === "UpstashError") {
61 | let desc = error.message;
62 |
63 | // Because the message does not fit in the toast, we only take the
64 | // first two sentences.
65 | // Example: "ERR max daily request limit exceeded. Limit: 10000, Usage: 10000."
66 | if (error.message.includes("max daily request limit exceeded.")) {
67 | desc = error.message.split(".").slice(0, 2).join(".");
68 | }
69 |
70 | toast({
71 | variant: "destructive",
72 | title: "Error",
73 | description: desc,
74 | });
75 | }
76 | console.error(error);
77 | },
78 | }),
79 | });
80 |
--------------------------------------------------------------------------------
/packages/react-databrowser/src/components/ui/table.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | import { cn } from "@/lib/utils";
4 |
5 | const Table = React.forwardRef>(
6 | ({ className, ...props }, ref) => (
7 |
10 | ),
11 | );
12 | Table.displayName = "Table";
13 |
14 | const TableHeader = React.forwardRef>(
15 | ({ className, ...props }, ref) => ,
16 | );
17 | TableHeader.displayName = "TableHeader";
18 |
19 | const TableBody = React.forwardRef>(
20 | ({ className, ...props }, ref) => (
21 |
22 | ),
23 | );
24 | TableBody.displayName = "TableBody";
25 |
26 | const TableFooter = React.forwardRef>(
27 | ({ className, ...props }, ref) => (
28 |
33 | ),
34 | );
35 | TableFooter.displayName = "TableFooter";
36 |
37 | const TableRow = React.forwardRef>(
38 | ({ className, ...props }, ref) => (
39 |
47 | ),
48 | );
49 | TableRow.displayName = "TableRow";
50 |
51 | const TableHead = React.forwardRef>(
52 | ({ className, ...props }, ref) => (
53 | [role=checkbox]]:translate-y-[2px]",
57 | className,
58 | )}
59 | {...props}
60 | />
61 | ),
62 | );
63 | TableHead.displayName = "TableHead";
64 |
65 | const TableCell = React.forwardRef>(
66 | ({ className, ...props }, ref) => (
67 | [role=checkbox]]:translate-y-[2px]", className)}
70 | {...props}
71 | />
72 | ),
73 | );
74 | TableCell.displayName = "TableCell";
75 |
76 | const TableCaption = React.forwardRef>(
77 | ({ className, ...props }, ref) => (
78 |
79 | ),
80 | );
81 | TableCaption.displayName = "TableCaption";
82 |
83 | export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption };
84 |
--------------------------------------------------------------------------------
/packages/react-databrowser/src/components/databrowser/components/data-display-container/display-scrollarea.tsx:
--------------------------------------------------------------------------------
1 | import { ScrollArea } from "@/components/ui/scroll-area";
2 | import { cn } from "@/lib/utils";
3 | import { Editor } from "@monaco-editor/react";
4 |
5 | type Props = {
6 | isRawView: boolean;
7 | rawData: string;
8 | isContentEditable: boolean;
9 | onContentChange: (text?: string) => void;
10 | };
11 | export const DisplayScrollarea = ({ rawData, isRawView, isContentEditable, onContentChange }: Props) => {
12 | const stringifiable = isRawView ? rawData : prettifyData(rawData);
13 |
14 | return (
15 |
21 | {stringifiable ? (
22 |
50 | ) : null}
51 |
52 | );
53 | };
54 |
55 | export const prettifyData = (data: string) => {
56 | const object = rawToObject(data);
57 |
58 | return toJsonStringifiable(object);
59 | };
60 |
61 | const rawToObject = (rawData: string) => {
62 | try {
63 | return JSON.parse(rawData);
64 | } catch (_error) {
65 | return rawData;
66 | }
67 | };
68 |
69 | export const toJsonStringifiable = (content: string | JSON | Record | null): string => {
70 | try {
71 | if (typeof content === "object" && content !== null) {
72 | try {
73 | return JSON.stringify(sortObject(content, true), null, 2);
74 | } catch (sortError) {
75 | console.error("Error sorting object:", sortError);
76 | }
77 | }
78 |
79 | return JSON.stringify(content, null, 2) ?? content?.toString() ?? "";
80 | } catch (error) {
81 | console.error("Unexpected error stringifying content:", error);
82 | return "";
83 | }
84 | };
85 |
86 | // Answer found here: https://stackoverflow.com/a/62552623
87 | // biome-ignore lint/suspicious/noExplicitAny:
88 | const sortObject = (unordered: [] | Record | null, sortArrays = false) => {
89 | if (!unordered || typeof unordered !== "object") {
90 | return unordered;
91 | }
92 |
93 | if (Array.isArray(unordered)) {
94 | return unordered;
95 | }
96 |
97 | // biome-ignore lint/suspicious/noExplicitAny:
98 | const ordered: Record = {};
99 | Object.keys(unordered)
100 | .sort()
101 | .forEach((key) => {
102 | ordered[key] = sortObject(unordered[key], sortArrays);
103 | });
104 | return ordered;
105 | };
106 |
--------------------------------------------------------------------------------
/packages/react-databrowser/src/components/databrowser/hooks/useFetchSingleDataByKey/index.ts:
--------------------------------------------------------------------------------
1 | import { useDatabrowser } from "@/store";
2 | import type { RedisDataTypeUnion } from "@/types";
3 | import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4 | import { useQuery } from "@tanstack/react-query";
5 | import { fetchDataOfType } from "./fetch-data-types";
6 | import { DATA_PER_PAGE, INITIAL_CURSOR_NUM } from "./utils";
7 |
8 | //TODO: Address the issue of useEffect taking additional time to reset the cursor when switching between identical data types, which results in unnecessary,
9 | // erroneous calls to the database. This needs to be resolved later.
10 |
11 | export type Navigation = {
12 | handlePageChange: (dir: "next" | "prev") => void;
13 | prevNotAllowed: boolean;
14 | nextNotAllowed: boolean;
15 | };
16 |
17 | export const useFetchSingleDataByKey = (
18 | selectedDataKeyTypePair: [string, RedisDataTypeUnion],
19 | dataFetchTimestamp: number,
20 | ) => {
21 | const { redis } = useDatabrowser();
22 |
23 | //Used for correctly resetting inner state of useQuery
24 | // biome-ignore lint/correctness/useExhaustiveDependencies:
25 | const cursorStack = useRef<(string | number)[]>([INITIAL_CURSOR_NUM]);
26 | const listLength = useRef(INITIAL_CURSOR_NUM);
27 | const [currentIndex, setCurrentIndex] = useState(INITIAL_CURSOR_NUM);
28 |
29 | const handlePageChange = useCallback(
30 | (dir: "next" | "prev") => {
31 | if (dir === "next") {
32 | setCurrentIndex((prev) => prev + 1);
33 | } else if (dir === "prev" && currentIndex > 0) {
34 | setCurrentIndex((prev) => prev - 1);
35 | }
36 | },
37 | [currentIndex],
38 | );
39 |
40 | // biome-ignore lint/correctness/useExhaustiveDependencies:
41 | useEffect(() => {
42 | setCurrentIndex(INITIAL_CURSOR_NUM);
43 | cursorStack.current = [INITIAL_CURSOR_NUM];
44 | listLength.current = INITIAL_CURSOR_NUM;
45 | }, [selectedDataKeyTypePair[0], dataFetchTimestamp]);
46 |
47 | const { isLoading, error, data } = useQuery({
48 | queryKey: [
49 | "useFetchSingleDataByKey",
50 | selectedDataKeyTypePair[0],
51 | cursorStack.current[currentIndex],
52 | currentIndex,
53 | dataFetchTimestamp,
54 | ],
55 | queryFn: async () => {
56 | const [key, dataType] = selectedDataKeyTypePair;
57 | if (Object.keys(fetchDataOfType).includes(dataType)) {
58 | return fetchDataOfType[dataType as Exclude]({
59 | key,
60 | redis,
61 | cursor: cursorStack.current[currentIndex],
62 | index: currentIndex,
63 | cursorStack,
64 | listLength: dataType === "list" ? listLength : undefined,
65 | });
66 | }
67 | console.error(`Unsupported data type: ${dataType}`);
68 | return { content: null, type: "unknown", memory: null } satisfies {
69 | content: null;
70 | type: "unknown";
71 | memory: null;
72 | };
73 | },
74 | });
75 |
76 | const isPrevNotAllowed = () => currentIndex === 0;
77 | const isNextNotAllowedForListType = () => (currentIndex + 1) * DATA_PER_PAGE >= listLength.current;
78 | const isNextNotAllowedForOtherTypes = () => cursorStack.current[currentIndex + 1] === 0;
79 | const isNextNotAllowed = () =>
80 | isLoading || selectedDataKeyTypePair[1] === "list"
81 | ? isNextNotAllowedForListType()
82 | : isNextNotAllowedForOtherTypes();
83 |
84 | return {
85 | isLoading,
86 | error,
87 | data,
88 | navigation: {
89 | handlePageChange,
90 | prevNotAllowed: isPrevNotAllowed(),
91 | nextNotAllowed: isNextNotAllowed(),
92 | } satisfies Navigation,
93 | };
94 | };
95 |
--------------------------------------------------------------------------------
/packages/react-databrowser/src/components/databrowser/components/data-display-container/data-value-edit.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@/components/ui/button";
2 | import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
3 | import { CopyToClipboardButton, handleCopyClick } from "../../copy-to-clipboard-button";
4 | import { prettifyData } from "./display-scrollarea";
5 | import { IconBraces } from "../icons/icon-braces";
6 | import { Checkbox } from "@/components/ui/checkbox";
7 |
8 | type Props = {
9 | onContentEditableToggle: () => void;
10 | isContentEditable: boolean;
11 | onContentEditableSave: () => Promise;
12 | data: string | JSON | null;
13 |
14 | isRawView: boolean;
15 | setRawView: (value: boolean) => void;
16 | showRawCheckbox?: boolean;
17 | };
18 | export const DataValueEdit = ({
19 | isContentEditable,
20 | onContentEditableToggle,
21 | onContentEditableSave,
22 | data,
23 | isRawView,
24 | setRawView,
25 | showRawCheckbox,
26 | }: Props) => {
27 | return (
28 |
29 | {/* Raw view checkbox */}
30 | {!isContentEditable && showRawCheckbox && (
31 |
32 |
33 |
34 | setRawView(!check)}>
35 |
36 |
37 |
38 |
39 | {isRawView ? "Raw view" : "Pretty print"}
40 |
41 |
42 |
43 | )}
44 |
45 | {/* Save button */}
46 | {isContentEditable && (
47 |
70 | )}
71 |
72 | {/* Copy button */}
73 | {!isContentEditable && (
74 | handleCopyClick(typeof data === "string" ? data : JSON.stringify(data))}
76 | svgSize={{ w: 20, h: 20 }}
77 | className="h-8 w-8 rounded-md border border-[#D9D9D9]"
78 | />
79 | )}
80 |
81 | {/* Edit button */}
82 |
83 |
84 |
85 |
86 |
101 |
102 |
103 |
104 | You can edit items in-place
105 |
106 |
107 |
108 |
109 | );
110 | };
111 |
--------------------------------------------------------------------------------
/packages/react-cli/src/cli.css:
--------------------------------------------------------------------------------
1 | .upstash-cli {
2 | font-family: monospace;
3 | position: relative;
4 | display: flex;
5 | flex-direction: column;
6 | width: 100%;
7 | height: 100%;
8 |
9 | color: #f8f8f8;
10 |
11 | background-color: #000;
12 |
13 | padding: 1rem;
14 | }
15 |
16 |
17 | .upstash-cli-viewport {
18 | display: flex;
19 | flex-direction: column;
20 | flex-grow: 1;
21 | width: 100%;
22 | height: 100%;
23 | word-break: break-all;
24 |
25 | }
26 |
27 | .upstash-cli-loading {
28 | animation-name: pulse;
29 | animation-duration: 5s;
30 | animation-timing-function: ease-in-out;
31 | animation-iteration-count: infinite;
32 |
33 | }
34 |
35 | @keyframes pulse {
36 | 0% {
37 | opacity: 1;
38 | }
39 |
40 | 50% {
41 | opacity: 0.7;
42 | }
43 |
44 | 100% {
45 | opacity: 1;
46 | }
47 | }
48 |
49 | .upstash-cli-stdin {
50 | width: 100%;
51 | font-family: monospace;
52 | background-color: transparent;
53 | border: none;
54 | outline: none;
55 | color: #fff;
56 | caret-color: #00e9a3;
57 | overflow-x: scroll;
58 | scroll-margin: 1rem;
59 | padding: 0;
60 | margin:0;
61 |
62 | /* border: 1px dashed white; */
63 |
64 | }
65 |
66 | .upstash-cli-stdin:focus::placeholder {
67 | color: transparent;
68 | }
69 |
70 | .upstash-cli-stdin:focus {
71 | outline: none;
72 | }
73 |
74 |
75 | .upstash-cli-scrollbar {
76 | display: flex;
77 | user-select: none;
78 |
79 | touch-action: none;
80 |
81 | padding: 0.5rem;
82 |
83 | background-color: black;
84 |
85 | transition-property: background-color;
86 |
87 | transition-duration: 150ms;
88 |
89 | transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
90 |
91 | }
92 |
93 | .upstash-cli-scrollbar:hover {
94 | background-color: black;
95 |
96 | }
97 |
98 |
99 |
100 | .upstash-cli-scrollbar[data-orientation="vertical"] {
101 | width: 0.625rem;
102 |
103 | }
104 |
105 | .upstash-cli-scrollbar[data-orientation="horizontal"] {
106 | flex-direction: column;
107 |
108 | height: 0.625rem;
109 |
110 | }
111 |
112 |
113 | .upstash-cli-scrollbar-thumb {
114 | display: flex;
115 | flex: 1;
116 |
117 | background-color: #4B5563;
118 |
119 | border-radius: 10px;
120 |
121 | position: relative;
122 | }
123 |
124 | .upstash-cli-scrollbar-thumb::before {
125 | content: '';
126 | display: block;
127 | position: absolute;
128 | top: 50%;
129 |
130 | left: 50%;
131 |
132 | transform: translate(-50%, -50%);
133 |
134 | width: 100%;
135 |
136 | height: 100%;
137 |
138 | min-width: 44px;
139 |
140 | min-height: 44px;
141 |
142 | }
143 |
144 |
145 | .upstash-cli-line {
146 | position: relative;
147 | display: flex;
148 | align-items: center;
149 |
150 | padding-top: 0.5rem;
151 | padding-bottom: 0.5rem;
152 |
153 | margin-right: 2rem;
154 | /* width: 100%; */
155 | /* min-height: 1rem; */
156 | /* border: 1px dashed red; */
157 |
158 | }
159 |
160 | .upstash-cli-line-prefix {
161 | /* position: absolute; */
162 | width: 1rem;
163 | height: 100%;
164 | /* border: 1px dashed yellow; */
165 |
166 | }
167 |
168 | .upstash-cli-line-content {
169 |
170 | width: 100%;
171 | /* padding-right: 1rem; */
172 | margin-right: 1rem;
173 | /* border: 1px dashed green; */
174 |
175 | }
176 |
177 |
178 | .upstash-cli-result {
179 | white-space: pre-wrap;
180 | word-break: break-word;
181 | width: 100%;
182 | font-family: monospace;
183 | background-color: transparent;
184 | border: none;
185 | outline: none;
186 | color: #fff;
187 | overflow-x: hidden;
188 | }
189 |
190 |
191 | .upstash-cli-help-command {
192 | display: flex;
193 | flex-direction: column;
194 | align-items: flex-start;
195 | padding: 0.5rem;
196 | margin-top: 0.5rem;
197 | border: 1px solid;
198 | }
199 |
200 | .upstash-cli-code {
201 |
202 | background-color: transparent;
203 | color: #a0aec0;
204 | background: none;
205 | border: none;
206 | padding: 0;
207 | outline: inherit;
208 | cursor: pointer;
209 |
210 |
211 | }
212 |
213 | .upstash-cli-link {
214 | color: #00e9a3;
215 | }
216 |
217 | .upstash-cli-link:hover {
218 | text-decoration: underline;
219 |
220 | }
--------------------------------------------------------------------------------
/packages/react-databrowser/src/components/databrowser/components/data-display-container/ttl-popover.tsx:
--------------------------------------------------------------------------------
1 | import { type PropsWithChildren, useState } from "react";
2 | import { Button } from "@/components/ui/button";
3 | import { Input } from "@/components/ui/input";
4 | import { useToast } from "@/components/ui/use-toast";
5 | import { usePersistTTL, useUpdateTTL } from "@/components/databrowser/hooks/useUpdateTTL";
6 | import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
7 | import { Label } from "@/components/ui/label";
8 | import { Spinner } from "@/components/ui/spinner";
9 |
10 | // We show None when expiration we recieve from server is -1
11 | const PERSISTED_KEY = -1;
12 |
13 | export function TTLPopover({ children, TTL, dataKey }: PropsWithChildren<{ TTL?: number; dataKey: string }>) {
14 | const { toast } = useToast();
15 | const [newTTL, setNewTTL] = useState();
16 |
17 | const updateTTL = useUpdateTTL();
18 | const persistTTL = usePersistTTL();
19 |
20 | const handleTTLChange = (newTTLValue: number) => setNewTTL(newTTLValue);
21 | const handleUpdateTTL = async (isClosed: boolean, newTTLValue?: number) => {
22 | try {
23 | if (isClosed && newTTLValue && newTTLValue !== TTL) {
24 | const ok = await updateTTL.mutateAsync({ dataKey, newTTLValue });
25 | if (ok) {
26 | toast({
27 | title: "Time Limit Set: Your Key is Now Temporary",
28 | description: "The expiration time for your key has been successfully updated.",
29 | });
30 | } else {
31 | toast({
32 | variant: "destructive",
33 | title: "Uh oh! Something went wrong.",
34 | description: "There was a problem with your request.",
35 | });
36 | }
37 | }
38 | } catch (error) {
39 | toast({
40 | variant: "destructive",
41 | title: "Uh oh! Something went wrong.",
42 | description: (error as Error).message,
43 | });
44 | } finally {
45 | setNewTTL(undefined);
46 | }
47 | };
48 |
49 | const handlePersistTTL = async () => {
50 | try {
51 | const ok = await persistTTL.mutateAsync(dataKey);
52 | if (ok) {
53 | toast({
54 | title: "Persist Success: Key Now Permanent",
55 | description: "Confirmed! Your key has been set to remain indefinitely.",
56 | });
57 | } else {
58 | toast({
59 | variant: "destructive",
60 | title: "Uh oh! Something went wrong.",
61 | description: "Your key might be already persisted.",
62 | });
63 | }
64 | setNewTTL(PERSISTED_KEY);
65 | } catch (error) {
66 | toast({
67 | variant: "destructive",
68 | title: "Uh oh! Something went wrong.",
69 | description: (error as Error).message,
70 | });
71 | }
72 | };
73 | return (
74 | handleUpdateTTL(!isOpen, newTTL)}>
75 |
76 |
77 |
78 |
79 |
80 |
81 | Expiration
82 | Set the expiration for the key.
83 |
84 |
85 |
86 |
87 | {
93 | handleTTLChange(currentTarget.valueAsNumber);
94 | }}
95 | />
96 |
97 | {TTL !== PERSISTED_KEY ? (
98 |
99 |
100 | Clicking this button will prevent your data from being automatically deleted after a certain period.
101 |
102 |
103 |
108 |
109 | ) : (
110 |
111 | TTL sets a timer to automatically delete keys after a defined period.
112 |
113 | )}
114 |
115 |
116 |
117 |
118 | );
119 | }
120 |
--------------------------------------------------------------------------------
/packages/react-databrowser/src/components/ui/use-toast.ts:
--------------------------------------------------------------------------------
1 | // Inspired by react-hot-toast library
2 | import * as React from "react";
3 |
4 | import type { ToastActionElement, ToastProps } from "@/components/ui/toast";
5 |
6 | const TOAST_LIMIT = 1;
7 | const TOAST_REMOVE_DELAY = 1000000;
8 |
9 | type ToasterToast = ToastProps & {
10 | id: string;
11 | title?: React.ReactNode;
12 | description?: React.ReactNode;
13 | action?: ToastActionElement;
14 | };
15 |
16 | const actionTypes = {
17 | ADD_TOAST: "ADD_TOAST",
18 | UPDATE_TOAST: "UPDATE_TOAST",
19 | DISMISS_TOAST: "DISMISS_TOAST",
20 | REMOVE_TOAST: "REMOVE_TOAST",
21 | } as const;
22 |
23 | let count = 0;
24 |
25 | function genId() {
26 | count = (count + 1) % Number.MAX_VALUE;
27 | return count.toString();
28 | }
29 |
30 | type ActionType = typeof actionTypes;
31 |
32 | type Action =
33 | | {
34 | type: ActionType["ADD_TOAST"];
35 | toast: ToasterToast;
36 | }
37 | | {
38 | type: ActionType["UPDATE_TOAST"];
39 | toast: Partial;
40 | }
41 | | {
42 | type: ActionType["DISMISS_TOAST"];
43 | toastId?: ToasterToast["id"];
44 | }
45 | | {
46 | type: ActionType["REMOVE_TOAST"];
47 | toastId?: ToasterToast["id"];
48 | };
49 |
50 | interface State {
51 | toasts: ToasterToast[];
52 | }
53 |
54 | const toastTimeouts = new Map>();
55 |
56 | const addToRemoveQueue = (toastId: string) => {
57 | if (toastTimeouts.has(toastId)) {
58 | return;
59 | }
60 |
61 | const timeout = setTimeout(() => {
62 | toastTimeouts.delete(toastId);
63 | dispatch({
64 | type: "REMOVE_TOAST",
65 | toastId: toastId,
66 | });
67 | }, TOAST_REMOVE_DELAY);
68 |
69 | toastTimeouts.set(toastId, timeout);
70 | };
71 |
72 | export const reducer = (state: State, action: Action): State => {
73 | switch (action.type) {
74 | case "ADD_TOAST":
75 | return {
76 | ...state,
77 | toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
78 | };
79 |
80 | case "UPDATE_TOAST":
81 | return {
82 | ...state,
83 | toasts: state.toasts.map((t) => (t.id === action.toast.id ? { ...t, ...action.toast } : t)),
84 | };
85 |
86 | case "DISMISS_TOAST": {
87 | const { toastId } = action;
88 |
89 | // ! Side effects ! - This could be extracted into a dismissToast() action,
90 | // but I'll keep it here for simplicity
91 | if (toastId) {
92 | addToRemoveQueue(toastId);
93 | } else {
94 | state.toasts.forEach((toast) => {
95 | addToRemoveQueue(toast.id);
96 | });
97 | }
98 |
99 | return {
100 | ...state,
101 | toasts: state.toasts.map((t) =>
102 | t.id === toastId || toastId === undefined
103 | ? {
104 | ...t,
105 | open: false,
106 | }
107 | : t,
108 | ),
109 | };
110 | }
111 | case "REMOVE_TOAST":
112 | if (action.toastId === undefined) {
113 | return {
114 | ...state,
115 | toasts: [],
116 | };
117 | }
118 | return {
119 | ...state,
120 | toasts: state.toasts.filter((t) => t.id !== action.toastId),
121 | };
122 | }
123 | };
124 |
125 | const listeners: Array<(state: State) => void> = [];
126 |
127 | let memoryState: State = { toasts: [] };
128 |
129 | function dispatch(action: Action) {
130 | memoryState = reducer(memoryState, action);
131 | listeners.forEach((listener) => {
132 | listener(memoryState);
133 | });
134 | }
135 |
136 | type Toast = Omit;
137 |
138 | function toast({ ...props }: Toast) {
139 | const id = genId();
140 |
141 | const update = (props: ToasterToast) =>
142 | dispatch({
143 | type: "UPDATE_TOAST",
144 | toast: { ...props, id },
145 | });
146 | const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
147 |
148 | dispatch({
149 | type: "ADD_TOAST",
150 | toast: {
151 | ...props,
152 | id,
153 | open: true,
154 | onOpenChange: (open) => {
155 | if (!open) {
156 | dismiss();
157 | }
158 | },
159 | },
160 | });
161 |
162 | return {
163 | id: id,
164 | dismiss,
165 | update,
166 | };
167 | }
168 |
169 | function useToast() {
170 | const [state, setState] = React.useState(memoryState);
171 |
172 | React.useEffect(() => {
173 | listeners.push(setState);
174 | return () => {
175 | const index = listeners.indexOf(setState);
176 | if (index > -1) {
177 | listeners.splice(index, 1);
178 | }
179 | };
180 | }, []);
181 |
182 | return {
183 | ...state,
184 | toast,
185 | dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
186 | };
187 | }
188 |
189 | export { useToast, toast };
190 |
--------------------------------------------------------------------------------
/packages/react-databrowser/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # @upstash/react-databrowser
2 |
3 | ## 0.3.20
4 |
5 | ### Patch Changes
6 |
7 | - 8df8484: add keepAlive:false back
8 |
9 | ## 0.3.19
10 |
11 | ### Patch Changes
12 |
13 | - f6872f5: fix alignment of cancel button in delete modal
14 |
15 | ## 0.3.18
16 |
17 | ### Patch Changes
18 |
19 | - 9ffa44d: Refresh data display when the same key or reload button is clicked
20 |
21 | ## 0.3.17
22 |
23 | ### Patch Changes
24 |
25 | - 64df54a: Disable sorting arrays when pretty printing
26 |
27 | ## 0.3.16
28 |
29 | ### Patch Changes
30 |
31 | - 6dd82f0: disable keepAlive
32 |
33 | ## 0.3.15
34 |
35 | ### Patch Changes
36 |
37 | - 58df4bc: fix bug with cursor parsing
38 |
39 | ## 0.3.14
40 |
41 | ### Patch Changes
42 |
43 | - a3caa2b: Log all fetch errors to console
44 |
45 | ## 0.3.13
46 |
47 | ### Patch Changes
48 |
49 | - 5d761ff: fix bug with json data not getting prettified
50 | - 76aff24: Bumping upstash/redis to version 1.31.3
51 |
52 | ## 0.3.12
53 |
54 | ### Patch Changes
55 |
56 | - 3663026: Fix JSON serialization issue
57 |
58 | ## 0.3.11
59 |
60 | ### Patch Changes
61 |
62 | - 12dfe68: fix small bug with pagination of zset, set, hash and lists
63 | - a8181db: fix bug with json editing not working because of pipeline
64 |
65 | ## 0.3.10
66 |
67 | ### Patch Changes
68 |
69 | - 078a577: Fix last page sometimes being empty
70 | - bb40047: Fix deserialization bug
71 | - 73aa996: Enable autoPipeline in the redis client
72 |
73 | ## 0.3.9
74 |
75 | ### Patch Changes
76 |
77 | - e648094: fix pagination not resetting when type filter changes
78 | - 893eecc: Implement a better way for fetching keys and their types using less commands
79 |
80 | ## 0.3.8
81 |
82 | ### Patch Changes
83 |
84 | - 61283d6: Fix problems with pagination logic, adding and the toaster
85 |
86 | ## 0.3.7
87 |
88 | ### Patch Changes
89 |
90 | - 67822e6: Add tooltip for long contents
91 |
92 | ## 0.3.6
93 |
94 | ### Patch Changes
95 |
96 | - 76c50c5: Added tooltip for truncated labels.
97 |
98 | ## 0.3.5
99 |
100 | ### Patch Changes
101 |
102 | - Added button to copy object fields
103 |
104 | ## 0.3.4
105 |
106 | ### Patch Changes
107 |
108 | - 2e09e1c: Prevent databrowser to fail when listing redis key
109 |
110 | ## 0.3.3
111 |
112 | ### Patch Changes
113 |
114 | - c747134: Fix json stringify logic
115 |
116 | ## 0.3.2
117 |
118 | ### Patch Changes
119 |
120 | - ab3c6a9: Fix key parsing issue when ratelimiter analytics active
121 |
122 | ## 0.3.1
123 |
124 | ### Patch Changes
125 |
126 | - 079369b: Add timestamp to key fetcher to always keep in sycn with up to date data
127 |
128 | ## 0.3.0
129 |
130 | ### Minor Changes
131 |
132 | - 1d59d01: Improved search bar by taking entire space for easier use,
133 | Replace editor with monaco editor for better DX
134 | Replaced skeleton loader with spinner
135 |
136 | ## 0.2.10
137 |
138 | ### Patch Changes
139 |
140 | - b077fd7: Fix HSET parsing issue
141 |
142 | ## 0.2.9
143 |
144 | ### Patch Changes
145 |
146 | - b36a874: Fixed hash set ordering and made data update easier
147 |
148 | ## 0.2.8
149 |
150 | ### Patch Changes
151 |
152 | - b3a50ab: Fixed search and added ability to edit values for string and json
153 |
154 | ## 0.2.7
155 |
156 | ### Patch Changes
157 |
158 | - 8b5bd86: Change name of the button
159 |
160 | ## 0.2.6
161 |
162 | ### Patch Changes
163 |
164 | - ab48ae0: Increase the size of add modal and disable hover of type tags
165 |
166 | ## 0.2.5
167 |
168 | ### Patch Changes
169 |
170 | - 9e43697: Delete type attr from buttons
171 |
172 | ## 0.2.4
173 |
174 | ### Patch Changes
175 |
176 | - e1c748c: Move save changes long css to global
177 |
178 | ## 0.2.3
179 |
180 | ### Patch Changes
181 |
182 | - e136904: Use plain button assign all the classes manually
183 |
184 | ## 0.2.2
185 |
186 | ### Patch Changes
187 |
188 | - 609ab32: Remove typeof check from transform method used for parsing hashes
189 |
190 | ## 0.2.1
191 |
192 | ### Patch Changes
193 |
194 | - e3f2e38: Minor fixes for resetting states
195 |
196 | ## 0.2.0
197 |
198 | ### Minor Changes
199 |
200 | - 4d8d0a6: Style changes to databrowser and disabling of sourcemap and allowing minimize in cli
201 |
202 | ## 0.1.1
203 |
204 | ### Patch Changes
205 |
206 | - dd0964c: Fix style issues
207 |
208 | ## 0.1.0
209 |
210 | ### Minor Changes
211 |
212 | - 119e7be: Add ability to view streams in databrowser
213 |
214 | ## 0.0.7
215 |
216 | ### Patch Changes
217 |
218 | - ff6d5ba: Bumped postcss version to get rid of dependabot warning
219 |
220 | ## 0.0.6
221 |
222 | ### Patch Changes
223 |
224 | - c51eb3a: Applied formatter and linter
225 |
226 | ## 0.0.5
227 |
228 | ### Patch Changes
229 |
230 | - 3b2095b: update publishConfig
231 |
232 | ## 0.0.4
233 |
234 | ### Patch Changes
235 |
236 | - f393b2f: do something
237 |
238 | ## 0.0.3
239 |
240 | ### Patch Changes
241 |
242 | - Allow passing token and url as a prop addition to env keys
243 |
244 | ## 0.0.2
245 |
246 | ### Patch Changes
247 |
248 | - first release
249 |
--------------------------------------------------------------------------------
/packages/react-databrowser/src/components/ui/alert-dialog.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
3 |
4 | import { cn } from "@/lib/utils";
5 | import { buttonVariants } from "@/components/ui/button";
6 |
7 | const AlertDialog = AlertDialogPrimitive.Root;
8 |
9 | const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
10 |
11 | const AlertDialogPortal = ({ ...props }: AlertDialogPrimitive.AlertDialogPortalProps) => (
12 |
13 | );
14 | AlertDialogPortal.displayName = AlertDialogPrimitive.Portal.displayName;
15 |
16 | const AlertDialogOverlay = React.forwardRef<
17 | React.ElementRef,
18 | React.ComponentPropsWithoutRef
19 | >(({ className, ...props }, ref) => (
20 |
28 | ));
29 | AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
30 |
31 | const AlertDialogContent = React.forwardRef<
32 | React.ElementRef,
33 | React.ComponentPropsWithoutRef
34 | >(({ className, ...props }, ref) => (
35 |
36 |
37 |
45 |
46 | ));
47 | AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
48 |
49 | const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes) => (
50 |
51 | );
52 | AlertDialogHeader.displayName = "AlertDialogHeader";
53 |
54 | const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes) => (
55 |
56 | );
57 | AlertDialogFooter.displayName = "AlertDialogFooter";
58 |
59 | const AlertDialogTitle = React.forwardRef<
60 | React.ElementRef,
61 | React.ComponentPropsWithoutRef
62 | >(({ className, ...props }, ref) => (
63 |
64 | ));
65 | AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
66 |
67 | const AlertDialogDescription = React.forwardRef<
68 | React.ElementRef,
69 | React.ComponentPropsWithoutRef
70 | >(({ className, ...props }, ref) => (
71 |
76 | ));
77 | AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName;
78 |
79 | const AlertDialogAction = React.forwardRef<
80 | React.ElementRef,
81 | React.ComponentPropsWithoutRef
82 | >(({ className, ...props }, ref) => (
83 |
84 | ));
85 | AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
86 |
87 | const AlertDialogCancel = React.forwardRef<
88 | React.ElementRef,
89 | React.ComponentPropsWithoutRef
90 | >(({ className, ...props }, ref) => (
91 |
96 | ));
97 | AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
98 |
99 | export {
100 | AlertDialog,
101 | AlertDialogTrigger,
102 | AlertDialogContent,
103 | AlertDialogHeader,
104 | AlertDialogFooter,
105 | AlertDialogTitle,
106 | AlertDialogDescription,
107 | AlertDialogAction,
108 | AlertDialogCancel,
109 | };
110 |
--------------------------------------------------------------------------------
/packages/react-databrowser/src/components/databrowser/hooks/useFetchSingleDataByKey/fetch-data-types.ts:
--------------------------------------------------------------------------------
1 | import type { Redis } from "@upstash/redis";
2 | import {
3 | type ContentValue,
4 | DATA_PER_PAGE,
5 | INITIAL_CURSOR_NUM,
6 | toJsonStringifiable,
7 | transformArray,
8 | transformHash,
9 | transformStream,
10 | } from "./utils";
11 |
12 | type FetchDataParams = {
13 | key: string;
14 | redis: Redis;
15 | cursor: number | string;
16 | index: number;
17 | cursorStack: React.MutableRefObject<(string | number)[]>;
18 | listLength?: React.MutableRefObject;
19 | };
20 |
21 | export const fetchDataOfType = {
22 | string: async ({ key, redis }: FetchDataParams) => {
23 | const content = await redis.get(key);
24 | return { content, type: "string", memory: roughSizeOfObject(content) } satisfies {
25 | content: string | null;
26 | type: "string";
27 | memory: number;
28 | };
29 | },
30 | zset: async ({ key, redis, cursor, index, cursorStack }: FetchDataParams) => {
31 | const [nextCursorStr, zrangeValue] = await redis.zscan(key, cursor as number, {
32 | count: DATA_PER_PAGE,
33 | });
34 | if (index === cursorStack.current.length - 1) {
35 | cursorStack.current.push(Number(nextCursorStr));
36 | }
37 | const content = transformArray(zrangeValue);
38 | return { content, type: "zset", memory: roughSizeOfObject(content) } satisfies {
39 | content: ContentValue[];
40 | type: "zset";
41 | memory: number;
42 | };
43 | },
44 | hash: async ({ key, redis, cursor, index, cursorStack }: FetchDataParams) => {
45 | const [nextCursorStr, hashValues] = await redis.hscan(key, cursor as number, {
46 | count: DATA_PER_PAGE,
47 | });
48 | if (index === cursorStack.current.length - 1) {
49 | cursorStack.current.push(Number(nextCursorStr));
50 | }
51 | const content = transformHash(hashValues);
52 | return { content, type: "hash", memory: roughSizeOfObject(content) } satisfies {
53 | content: ContentValue[];
54 | type: "hash";
55 | memory: number;
56 | };
57 | },
58 | set: async ({ key, redis, cursor, index, cursorStack }: FetchDataParams) => {
59 | const [nextCursorStr, setValues] = await redis.sscan(key, cursor as number, {
60 | count: DATA_PER_PAGE,
61 | });
62 | if (index === cursorStack.current.length - 1) {
63 | cursorStack.current.push(Number(nextCursorStr));
64 | }
65 | const content = setValues.map((item, _) => ({
66 | value: null,
67 | content: toJsonStringifiable(item, 0),
68 | }));
69 |
70 | return {
71 | content,
72 | memory: roughSizeOfObject(content),
73 | type: "set",
74 | } satisfies { content: ContentValue[]; type: "set"; memory: number };
75 | },
76 | list: async ({ key, redis, index, listLength }: FetchDataParams) => {
77 | if (listLength && listLength.current === INITIAL_CURSOR_NUM) {
78 | listLength.current = await redis.llen(key);
79 | }
80 | const start = index * DATA_PER_PAGE;
81 | const end = (index + 1) * DATA_PER_PAGE - 1;
82 | const list = await redis.lrange(key, start, end);
83 | const content = list.map((item, idx) => {
84 | const overallIdx = start + idx;
85 | return { value: overallIdx, content: toJsonStringifiable(item, 0) };
86 | });
87 |
88 | return {
89 | content,
90 | type: "list",
91 | memory: roughSizeOfObject(content),
92 | } satisfies { content: ContentValue[]; type: "list"; memory: number };
93 | },
94 | stream: async ({ key, redis, cursor, index, cursorStack }: FetchDataParams) => {
95 | const typedCursor = cursor as string | typeof INITIAL_CURSOR_NUM;
96 | const result = await redis.xrange(key, typedCursor === INITIAL_CURSOR_NUM ? "-" : typedCursor, "+", DATA_PER_PAGE);
97 |
98 | const transformedData = transformStream(result);
99 | //Last items timestamp is being used as next cursor
100 | const nextCursor = transformedData.at(-1)?.value;
101 | if (index === cursorStack.current.length - 1) {
102 | cursorStack.current.push(
103 | transformedData.length === DATA_PER_PAGE && nextCursor ? nextCursor : INITIAL_CURSOR_NUM,
104 | );
105 | }
106 |
107 | return { content: transformedData, type: "stream", memory: roughSizeOfObject(transformedData) } satisfies {
108 | content: ContentValue[];
109 | type: "stream";
110 | memory: number;
111 | };
112 | },
113 | json: async ({ key, redis }: FetchDataParams) => {
114 | const result = (await redis.json.get(key)) as string | null;
115 |
116 | return { content: result, type: "json", memory: roughSizeOfObject(result) } satisfies {
117 | content: string | null;
118 | type: "json";
119 | memory: number;
120 | };
121 | },
122 | };
123 |
124 | const roughSizeOfObject = (obj: unknown) => {
125 | let str = null;
126 | if (typeof obj === "string") {
127 | // If obj is a string, then use it
128 | str = obj;
129 | } else {
130 | // Else, make obj into a string
131 | str = JSON.stringify(obj);
132 | }
133 | // Get the length of the Uint8Array
134 | const bytes = new TextEncoder().encode(str).length;
135 | return bytes;
136 | };
137 |
--------------------------------------------------------------------------------
/packages/react-databrowser/src/components/ui/dialog.tsx:
--------------------------------------------------------------------------------
1 | import * as DialogPrimitive from "@radix-ui/react-dialog";
2 | import * as React from "react";
3 |
4 | import { cn } from "@/lib/utils";
5 |
6 | const Dialog = DialogPrimitive.Root;
7 |
8 | const DialogTrigger = DialogPrimitive.Trigger;
9 |
10 | const DialogPortal = (props: DialogPrimitive.DialogPortalProps) => ;
11 | DialogPortal.displayName = DialogPrimitive.Portal.displayName;
12 |
13 | const DialogOverlay = React.forwardRef<
14 | React.ElementRef,
15 | React.ComponentPropsWithoutRef
16 | >(({ className, ...props }, ref) => (
17 |
25 | ));
26 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
27 |
28 | const DialogContent = React.forwardRef<
29 | React.ElementRef,
30 | React.ComponentPropsWithoutRef
31 | >(({ className, children, ...props }, ref) => (
32 |
33 |
34 |
42 | {children}
43 |
44 |
59 | Close
60 |
61 |
62 |
63 | ));
64 | DialogContent.displayName = DialogPrimitive.Content.displayName;
65 |
66 | const DialogHeader = ({ className, ...props }: React.HTMLAttributes) => (
67 |
68 | );
69 | DialogHeader.displayName = "DialogHeader";
70 |
71 | const DialogFooter = ({ className, ...props }: React.HTMLAttributes) => (
72 |
73 | );
74 | DialogFooter.displayName = "DialogFooter";
75 |
76 | const DialogTitle = React.forwardRef<
77 | React.ElementRef,
78 | React.ComponentPropsWithoutRef
79 | >(({ className, ...props }, ref) => (
80 |
85 | ));
86 | DialogTitle.displayName = DialogPrimitive.Title.displayName;
87 |
88 | const DialogDescription = React.forwardRef<
89 | React.ElementRef,
90 | React.ComponentPropsWithoutRef
91 | >(({ className, ...props }, ref) => (
92 |
97 | ));
98 | DialogDescription.displayName = DialogPrimitive.Description.displayName;
99 |
100 | export { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger };
101 |
--------------------------------------------------------------------------------
/packages/react-databrowser/src/components/databrowser/components/data-display-container/data-table.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import { CopyToClipboardButton, handleCopyClick } from "@/components/databrowser/copy-to-clipboard-button";
3 | import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
4 | import { cn } from "@/lib/utils";
5 | import type { ContentValue } from "../../hooks/useFetchSingleDataByKey/utils";
6 | import { Tooltip, TooltipProvider, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
7 |
8 | type Props = {
9 | data: ContentValue[];
10 | tableHeaders: [string | null, string];
11 | };
12 | export const DataTable = ({ data, tableHeaders }: Props) => {
13 | const [hoveredRow, setHoveredRow] = useState(null);
14 |
15 | return (
16 |
17 |
18 |
19 |
20 | {tableHeaders[0] !== null ? (
21 | {tableHeaders[0]}
22 | ) : null}
23 | {tableHeaders[1] !== null ? (
24 | {tableHeaders[1]}
25 | ) : null}
26 |
27 |
28 |
29 | {data.map((item, idx) => (
30 | setHoveredRow(idx)}
34 | onMouseLeave={() => setHoveredRow(null)}
35 | >
36 | {item.value !== null ? (
37 |
45 |
46 |
47 |
48 |
49 | {item.value}
50 |
51 |
52 |
53 | {item.value}
54 |
55 |
56 |
57 |
58 | {hoveredRow === idx && (
59 |
60 | handleCopyClick(item.value !== null ? item.value.toString() : "")}
64 | svgSize={{ h: 22, w: 22 }}
65 | />
66 |
67 | )}
68 |
69 | ) : null}
70 | {item.content !== null ? (
71 |
80 |
81 |
82 |
83 |
84 | {item.content}
85 |
86 |
87 |
88 | {item.content}
89 |
90 |
91 |
92 | {hoveredRow === idx && (
93 |
94 | handleCopyClick(item.content.toString())}
98 | svgSize={{ h: 22, w: 22 }}
99 | />
100 |
101 | )}
102 |
103 | ) : null}
104 |
105 | ))}
106 |
107 |
108 |
109 | );
110 | };
111 |
--------------------------------------------------------------------------------
/packages/react-databrowser/src/components/ui/toast.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Cross2Icon } from "@radix-ui/react-icons";
3 | import * as ToastPrimitives from "@radix-ui/react-toast";
4 | import { cva, type VariantProps } from "class-variance-authority";
5 |
6 | import { cn } from "@/lib/utils";
7 |
8 | const ToastProvider = ToastPrimitives.Provider;
9 |
10 | const ToastViewport = React.forwardRef<
11 | React.ElementRef,
12 | React.ComponentPropsWithoutRef
13 | >(({ className, ...props }, ref) => (
14 |
22 | ));
23 | ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
24 |
25 | const toastVariants = cva(
26 | "group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border border-neutral-200 p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full dark:border-neutral-800",
27 | {
28 | variants: {
29 | variant: {
30 | default: "border bg-white text-neutral-950 dark:bg-neutral-950 dark:text-neutral-50",
31 | destructive:
32 | "destructive group border-red-500 bg-red-500 text-neutral-50 dark:border-red-900 dark:bg-red-900 dark:text-neutral-50",
33 | },
34 | },
35 | defaultVariants: {
36 | variant: "default",
37 | },
38 | },
39 | );
40 |
41 | const Toast = React.forwardRef<
42 | React.ElementRef,
43 | React.ComponentPropsWithoutRef & VariantProps
44 | >(({ className, variant, ...props }, ref) => {
45 | return ;
46 | });
47 | Toast.displayName = ToastPrimitives.Root.displayName;
48 |
49 | const ToastAction = React.forwardRef<
50 | React.ElementRef,
51 | React.ComponentPropsWithoutRef
52 | >(({ className, ...props }, ref) => (
53 |
61 | ));
62 | ToastAction.displayName = ToastPrimitives.Action.displayName;
63 |
64 | const ToastClose = React.forwardRef<
65 | React.ElementRef,
66 | React.ComponentPropsWithoutRef
67 | >(({ className, ...props }, ref) => (
68 |
77 |
78 |
79 | ));
80 | ToastClose.displayName = ToastPrimitives.Close.displayName;
81 |
82 | const ToastTitle = React.forwardRef<
83 | React.ElementRef,
84 | React.ComponentPropsWithoutRef
85 | >(({ className, ...props }, ref) => (
86 |
87 | ));
88 | ToastTitle.displayName = ToastPrimitives.Title.displayName;
89 |
90 | const ToastDescription = React.forwardRef<
91 | React.ElementRef,
92 | React.ComponentPropsWithoutRef
93 | >(({ className, ...props }, ref) => (
94 |
95 | ));
96 | ToastDescription.displayName = ToastPrimitives.Description.displayName;
97 |
98 | type ToastProps = React.ComponentPropsWithoutRef;
99 |
100 | type ToastActionElement = React.ReactElement;
101 |
102 | export {
103 | type ToastProps,
104 | type ToastActionElement,
105 | ToastProvider,
106 | ToastViewport,
107 | Toast,
108 | ToastTitle,
109 | ToastDescription,
110 | ToastClose,
111 | ToastAction,
112 | };
113 |
--------------------------------------------------------------------------------
/packages/react-databrowser/src/components/databrowser/components/sidebar/index.tsx:
--------------------------------------------------------------------------------
1 | import { useFetchPaginatedKeys } from "@/components/databrowser/hooks/useFetchPaginatedKeys";
2 | import { Button } from "@/components/ui/button";
3 | import { Input } from "@/components/ui/input";
4 | import { cn } from "@/lib/utils";
5 | import type { RedisDataTypeUnion } from "@/types";
6 | import { ChevronLeftIcon, ChevronRightIcon } from "@radix-ui/react-icons";
7 | import { useEffect, useRef, useState } from "react";
8 | import { AddDataDialog } from "../add-data/add-data-dialog";
9 | import { DataKeyButtons } from "./data-key-buttons";
10 | import { DataTypeSelector } from "./data-type-selector";
11 | import { DisplayDbSize } from "./display-db-size";
12 | import { ReloadButton } from "./reload-button";
13 | import { SidebarMissingData } from "./sidebar-missing-data";
14 | import { LoadingSkeleton } from "./skeleton-buttons";
15 |
16 | type Props = {
17 | onDataKeyChange: (dataKey?: [string, RedisDataTypeUnion]) => void;
18 | selectedDataKey?: string;
19 | refetchData: () => void;
20 | };
21 |
22 | const useRefreshOnDelete = ({ dataKey, refresh }: { dataKey?: string; refresh: () => void }) => {
23 | const firstTime = useRef(true);
24 |
25 | useEffect(() => {
26 | if (firstTime.current) {
27 | firstTime.current = false;
28 | return;
29 | }
30 |
31 | if (dataKey === undefined) {
32 | refresh();
33 | }
34 | }, [refresh, dataKey]);
35 | };
36 |
37 | export function Sidebar({ onDataKeyChange, selectedDataKey, refetchData }: Props) {
38 | const [onInputFocus, setOnInputFocus] = useState(false);
39 | const [selectedDataType, setSelectedDataType] = useState();
40 | const {
41 | data: dataKeys,
42 | isLoading,
43 | error,
44 | handlePageChange,
45 | direction,
46 | handleSearch,
47 | refreshSearch,
48 | resetPagination,
49 | searchInputRef,
50 | } = useFetchPaginatedKeys(selectedDataType);
51 |
52 | const handleDataTypeChange = (dataType?: RedisDataTypeUnion) => {
53 | resetPagination();
54 | setSelectedDataType(dataType);
55 | };
56 |
57 | const handleDataAdd = (dataKey?: [string, RedisDataTypeUnion]) => {
58 | onDataKeyChange(dataKey);
59 | refreshSearch();
60 | };
61 |
62 | useRefreshOnDelete({ dataKey: selectedDataKey, refresh: refreshSearch });
63 |
64 | return (
65 |
66 |
67 |
68 |
69 |
70 | setOnInputFocus(true)}
74 | onBlur={() => setOnInputFocus(false)}
75 | className={cn(
76 | "h-[32px] w-[140px] items-center justify-center border-[#D9D9D9] px-4 text-[14px] placeholder-[#1F1F1F66] transition-[width] duration-300 ease-in-out focus-visible:ring-0 ",
77 | onInputFocus && "rounded- w-[320px] focus-visible:ring-0",
78 | )}
79 | onChange={(e) => handleSearch(e.target.value)}
80 | ref={searchInputRef}
81 | style={{
82 | borderTopLeftRadius: "8px",
83 | borderBottomLeftRadius: "8px",
84 | borderTopRightRadius: onInputFocus ? "8px" : 0,
85 | borderBottomRightRadius: onInputFocus ? "8px" : 0,
86 | }}
87 | />
88 |
97 |
98 |
99 |
100 |
101 | {error ? (
102 |
103 | ) : isLoading ? (
104 |
105 | ) : dataKeys?.length ? (
106 |
107 | ) : (
108 |
109 | )}
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 |
127 |
137 |
138 |
139 |
140 | );
141 | }
142 |
--------------------------------------------------------------------------------
/packages/react-databrowser/src/components/ui/select.tsx:
--------------------------------------------------------------------------------
1 | import * as SelectPrimitive from "@radix-ui/react-select";
2 | import * as React from "react";
3 |
4 | import { cn } from "@/lib/utils";
5 |
6 | const Select = SelectPrimitive.Root;
7 |
8 | const SelectGroup = SelectPrimitive.Group;
9 |
10 | const SelectValue = SelectPrimitive.Value;
11 |
12 | const SelectTrigger = React.forwardRef<
13 | React.ElementRef,
14 | React.ComponentPropsWithoutRef
15 | >(({ className, children, ...props }, ref) => (
16 |
24 | {children}
25 |
26 |
36 |
37 |
38 | ));
39 | SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
40 |
41 | const SelectContent = React.forwardRef<
42 | React.ElementRef,
43 | React.ComponentPropsWithoutRef
44 | >(({ className, children, position = "popper", ...props }, ref) => (
45 |
46 |
57 |
64 | {children}
65 |
66 |
67 |
68 | ));
69 | SelectContent.displayName = SelectPrimitive.Content.displayName;
70 |
71 | const SelectLabel = React.forwardRef<
72 | React.ElementRef,
73 | React.ComponentPropsWithoutRef
74 | >(({ className, ...props }, ref) => (
75 |
76 | ));
77 | SelectLabel.displayName = SelectPrimitive.Label.displayName;
78 |
79 | const SelectItem = React.forwardRef<
80 | React.ElementRef,
81 | React.ComponentPropsWithoutRef
82 | >(({ className, children, ...props }, ref) => (
83 |
91 |
92 |
93 |
94 |
109 |
110 |
111 |
112 | {children}
113 |
114 | ));
115 | SelectItem.displayName = SelectPrimitive.Item.displayName;
116 |
117 | const SelectSeparator = React.forwardRef<
118 | React.ElementRef,
119 | React.ComponentPropsWithoutRef
120 | >(({ className, ...props }, ref) => (
121 |
126 | ));
127 | SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
128 |
129 | export { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue };
130 |
--------------------------------------------------------------------------------
/packages/react-databrowser/src/components/databrowser/components/data-display-container/data-display.tsx:
--------------------------------------------------------------------------------
1 | import { useFetchSingleDataByKey, useFetchTTLByKey, useUpdateStringAndJSON } from "@/components/databrowser/hooks";
2 | import { RedisTypeTag } from "@/components/databrowser/type-tag";
3 | import { Button } from "@/components/ui/button";
4 | import type { RedisDataTypeUnion } from "@/types";
5 | import { ChevronLeftIcon, ChevronRightIcon } from "@radix-ui/react-icons";
6 | import { CopyToClipboardButton, handleCopyClick } from "../../copy-to-clipboard-button";
7 | import { DataDelete } from "./data-delete";
8 | import { DataTable } from "./data-table";
9 | import { DataTTLActions } from "./data-ttl-actions";
10 | import { DataValueEdit } from "./data-value-edit";
11 | import { DisplayScrollarea } from "./display-scrollarea";
12 | import { MissingDataDisplay } from "./missing-data-display";
13 | import { DataLoading } from "./data-loading";
14 | import { useState } from "react";
15 |
16 | type Props = {
17 | selectedDataKeyTypePair: [string, RedisDataTypeUnion];
18 | onDataKeyChange: (dataKey?: [string, RedisDataTypeUnion]) => void;
19 | dataFetchTimestamp: number;
20 | };
21 |
22 | export function DataDisplay({ selectedDataKeyTypePair, onDataKeyChange, dataFetchTimestamp }: Props) {
23 | const [key, keyType] = selectedDataKeyTypePair;
24 | const { data, isLoading, navigation, error } = useFetchSingleDataByKey(selectedDataKeyTypePair, dataFetchTimestamp);
25 |
26 | const { data: TTLData, isLoading: isTTLLoading } = useFetchTTLByKey(key);
27 | const {
28 | handleContentEditableToggle,
29 | handleContentUpdate,
30 | handleUpdatedContent,
31 | isContentEditable,
32 | updateDataStatus,
33 | } = useUpdateStringAndJSON(selectedDataKeyTypePair, TTLData);
34 |
35 | const [isRawView, setRawView] = useState(false);
36 |
37 | return (
38 |
39 |
40 |
41 | {key}
42 |
43 | handleCopyClick(key)} svgSize={{ w: 22, h: 22 }} />
44 |
45 |
46 |
47 |
48 |
49 |
50 | {isLoading || updateDataStatus === "pending" ? (
51 |
52 | ) : keyType === "string" && data?.type === "string" ? (
53 |
59 | ) : keyType === "json" && data?.type === "json" ? (
60 |
66 | ) : keyType === "zset" && data?.type === "zset" ? (
67 |
68 | ) : keyType === "hash" && data?.type === "hash" ? (
69 |
70 | ) : keyType === "list" && data?.type === "list" ? (
71 |
72 | ) : keyType === "set" && data?.type === "set" ? (
73 |
74 | ) : keyType === "stream" && data?.type === "stream" ? (
75 |
76 | ) : data?.type === "unknown" || error ? (
77 |
78 | ) : null}
79 |
80 |
81 |
82 |
83 | Memory: ~{data?.memory} bytes
84 |
85 | {((keyType === "string" && data?.type === "string") || (keyType === "json" && data?.type === "json")) && (
86 |
87 | handleContentUpdate()}
94 | isContentEditable={isContentEditable}
95 | />
96 |
97 | )}
98 |
99 | {keyType !== "json" && keyType !== "string" && (
100 |
101 |
111 |
121 |
122 | )}
123 |
124 |
125 | );
126 | }
127 |
--------------------------------------------------------------------------------
/packages/react-databrowser/src/components/databrowser/hooks/useFetchPaginatedKeys.ts:
--------------------------------------------------------------------------------
1 | import { queryClient } from "@/lib/clients";
2 | import { useDatabrowser } from "@/store";
3 | import { RedisDataTypes, type RedisDataTypeUnion } from "@/types";
4 | import { useCallback, useRef, useState } from "react";
5 | import { useQuery } from "@tanstack/react-query";
6 | import { useDebounce } from "./useDebounce";
7 | import type { Redis } from "@upstash/redis";
8 |
9 | const SCAN_MATCH_ALL = "*";
10 | const DEBOUNCE_TIME = 250;
11 |
12 | const PAGE_SIZE = 10;
13 |
14 | // Fetch 100 keys every single time
15 | const INITIAL_FETCH_COUNT = 100;
16 | const MAX_FETCH_COUNT = 1000;
17 |
18 | type RedisKey = [string, RedisDataTypeUnion];
19 |
20 | function slicePage(keys: RedisKey[], page: number) {
21 | return keys.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
22 | }
23 |
24 | const dataTypes = RedisDataTypes.filter((type) => type !== "All Types");
25 |
26 | class PaginatedRedis {
27 | constructor(
28 | private readonly redis: Redis,
29 | private readonly searchTerm: string,
30 | private readonly typeFilter: string | undefined,
31 | ) {
32 | // console.log("************** RESET");
33 | }
34 |
35 | // Cursor is 0 initially, then it is set to -1 when we reach the end
36 | cache: Record = Object.fromEntries(
37 | dataTypes.map((type) => [type, { cursor: "0", keys: [] }]),
38 | );
39 | targetCount = 0;
40 |
41 | private getLength() {
42 | return Object.values(this.cache).reduce((acc, curr) => acc + curr.keys.length, 0);
43 | }
44 |
45 | private getKeys() {
46 | const keys = Object.entries(this.cache).flatMap(([type, { keys }]) => {
47 | return keys.map((key) => [key, type] as RedisKey);
48 | });
49 |
50 | const sorted = keys.sort((a, b) => a[0].localeCompare(b[0]));
51 |
52 | return sorted;
53 | }
54 |
55 | private async fetch() {
56 | const fetchType = async (type: string) => {
57 | let fetchCount = INITIAL_FETCH_COUNT;
58 |
59 | while (true) {
60 | const cursor = this.cache[type].cursor;
61 | if (cursor === "-1" || this.getLength() >= this.targetCount) {
62 | break;
63 | }
64 |
65 | const [nextCursor, newKeys] = await this.redis.scan(cursor, {
66 | count: fetchCount,
67 | match: this.searchTerm,
68 | type: type,
69 | });
70 |
71 | fetchCount = Math.min(fetchCount * 2, MAX_FETCH_COUNT);
72 |
73 | // console.log("< scan", type, newKeys.length, nextCursor === 0 ? "END" : "MORE");
74 |
75 | // Dedupe here because redis can and will return duplicates for example when
76 | // a key is deleted because of ttl etc.
77 | const dedupedSet = new Set([...this.cache[type].keys, ...newKeys]);
78 |
79 | this.cache[type].keys = [...dedupedSet];
80 | this.cache[type].cursor = nextCursor === "0" ? "-1" : nextCursor;
81 | }
82 | };
83 |
84 | // Fetch pages of each type until they are enough
85 | const types = this.typeFilter ? [this.typeFilter] : dataTypes;
86 | await Promise.all(types.map(fetchType));
87 | }
88 |
89 | isFetching = false;
90 |
91 | private isAllEnded() {
92 | return (this.typeFilter ? [this.typeFilter] : dataTypes).every((type) => this.cache[type].cursor === "-1");
93 | }
94 |
95 | async getPage(page: number) {
96 | // +1 here to fetch one more than needed to check if there is a next page
97 | this.targetCount = (page + 1) * PAGE_SIZE + 1;
98 |
99 | if (!this.isFetching) {
100 | try {
101 | this.isFetching = true;
102 | void this.fetch();
103 | } finally {
104 | this.isFetching = false;
105 | }
106 | }
107 |
108 | // Wait until we have enough
109 | await new Promise((resolve) => {
110 | const interval = setInterval(() => {
111 | if (this.getLength() >= this.targetCount || this.isAllEnded()) {
112 | clearInterval(interval);
113 | resolve();
114 | }
115 | }, 100);
116 | });
117 |
118 | const hasEnoughForNextPage = this.getLength() > (page + 1) * PAGE_SIZE;
119 |
120 | const hasNextPage = !this.isAllEnded() || hasEnoughForNextPage;
121 |
122 | // console.log(slicePage(this.getKeys(), page));
123 |
124 | return {
125 | keys: slicePage(this.getKeys(), page),
126 | hasNextPage,
127 | };
128 | }
129 | }
130 |
131 | const useFetchRedisPage = ({
132 | searchTerm,
133 | typeFilter,
134 | page,
135 | }: {
136 | searchTerm: string;
137 | typeFilter?: RedisDataTypeUnion;
138 | page: number;
139 | }) => {
140 | const { redis } = useDatabrowser();
141 |
142 | const cache = useRef(undefined);
143 | const lastKey = useRef(undefined);
144 |
145 | const context = useQuery({
146 | queryKey: ["useFetchPaginatedKeys", searchTerm, typeFilter, page],
147 | queryFn: async () => {
148 | const newKey = `${searchTerm}-${typeFilter}`;
149 |
150 | if (!cache.current || lastKey.current !== newKey) {
151 | cache.current = new PaginatedRedis(redis, searchTerm, typeFilter);
152 | lastKey.current = newKey;
153 | }
154 |
155 | return cache.current.getPage(page);
156 | },
157 | });
158 |
159 | const resetCache = useCallback(() => {
160 | cache.current = undefined;
161 | lastKey.current = undefined;
162 | }, []);
163 |
164 | return {
165 | ...context,
166 | resetCache,
167 | };
168 | };
169 |
170 | export const useFetchPaginatedKeys = (dataType?: RedisDataTypeUnion) => {
171 | const allTypesIncluded = dataType === "All Types" ? undefined : dataType;
172 |
173 | const searchInputRef = useRef(null);
174 |
175 | const [searchTerm, setSearchTerm] = useState(SCAN_MATCH_ALL);
176 | const debouncedSearchTerm = useDebounce(searchTerm, DEBOUNCE_TIME);
177 |
178 | const [currentPage, setCurrentPage] = useState(0);
179 |
180 | const { resetCache, data, isLoading, error } = useFetchRedisPage({
181 | searchTerm: debouncedSearchTerm,
182 | typeFilter: allTypesIncluded,
183 | page: currentPage,
184 | });
185 |
186 | const handlePageChange = useCallback(
187 | (dir: "next" | "prev") => {
188 | if (dir === "next") {
189 | setCurrentPage((prev) => prev + 1);
190 | } else if (dir === "prev" && currentPage > 0) {
191 | setCurrentPage((prev) => prev - 1);
192 | }
193 | },
194 | [currentPage],
195 | );
196 |
197 | const resetPagination = () => {
198 | setCurrentPage(0);
199 | };
200 |
201 | // If user doesn't pass any asterisk we add two of them to end and start
202 | const handleSearch = (query: string) => {
203 | setSearchTerm(!query.includes("*") ? `*${query}*` : query);
204 | setCurrentPage(0);
205 | };
206 |
207 | const refreshSearch = useCallback(() => {
208 | resetCache();
209 | setCurrentPage(0);
210 |
211 | queryClient.resetQueries({
212 | queryKey: ["useFetchPaginatedKeys"],
213 | });
214 |
215 | queryClient.invalidateQueries({
216 | queryKey: ["useFetchDbSize"],
217 | });
218 | }, [resetCache]);
219 |
220 | return {
221 | isLoading,
222 | error,
223 | data: data?.keys,
224 | resetPagination,
225 | handlePageChange,
226 | handleSearch,
227 | refreshSearch,
228 | direction: {
229 | prevNotAllowed: currentPage <= 0,
230 | nextNotAllowed: data ? !data.hasNextPage : true,
231 | },
232 | searchInputRef,
233 | };
234 | };
235 |
--------------------------------------------------------------------------------
/packages/react-databrowser/src/components/databrowser/components/add-data/add-data-dialog.tsx:
--------------------------------------------------------------------------------
1 | import { useAddData } from "@/components/databrowser/hooks/useAddData";
2 | import { RedisTypeTag } from "@/components/databrowser/type-tag";
3 | import { Button } from "@/components/ui/button";
4 | import {
5 | Dialog,
6 | DialogContent,
7 | DialogDescription,
8 | DialogFooter,
9 | DialogHeader,
10 | DialogTitle,
11 | DialogTrigger,
12 | } from "@/components/ui/dialog";
13 | import { Input } from "@/components/ui/input";
14 | import {
15 | Select,
16 | SelectContent,
17 | SelectGroup,
18 | SelectItem,
19 | SelectLabel,
20 | SelectTrigger,
21 | SelectValue,
22 | } from "@/components/ui/select";
23 | import { Spinner } from "@/components/ui/spinner";
24 | import { Textarea } from "@/components/ui/textarea";
25 | import { useToast } from "@/components/ui/use-toast";
26 | import type { RedisDataTypeUnion } from "@/types";
27 | import { PlusIcon } from "@radix-ui/react-icons";
28 | import { Label } from "@radix-ui/react-label";
29 | import { type FormEvent, useState } from "react";
30 |
31 | const expUnit = ["Second(s)", "Minute(s)", "Hour(s)", "Day(s)", "Week(s)", "Month(s)", "Year(s)"] as const;
32 | export type ExpUnitUnion = (typeof expUnit)[number];
33 |
34 | type Props = {
35 | onNewDataAdd: (dataKey?: [string, RedisDataTypeUnion]) => void;
36 | };
37 | //TODO: Should be extended in the future to accept other data types.
38 | export function AddDataDialog({ onNewDataAdd }: Props) {
39 | const { toast } = useToast();
40 | const [open, setOpen] = useState(false);
41 |
42 | const addData = useAddData();
43 |
44 | const handleAddData = async (e: FormEvent) => {
45 | try {
46 | e.preventDefault();
47 | const formData = new FormData(e.currentTarget);
48 |
49 | const key = formData.get("key") as string;
50 | const value = formData.get("value") as string;
51 |
52 | if (!(key && value)) {
53 | throw new Error("Missing key or value data");
54 | }
55 |
56 | const exp = Number(formData.get("exp"));
57 | const expUnit = formData.get("exp-unit") as ExpUnitUnion | undefined;
58 | const ttl = expUnit ? convertToSeconds(expUnit, exp) : null;
59 | const ok = await addData.mutateAsync([key, value, ttl, false]);
60 |
61 | if (ok) {
62 | toast({
63 | description: "Data Set Successfully!",
64 | });
65 | onNewDataAdd([key, "string"]);
66 | } else {
67 | toast({
68 | variant: "destructive",
69 | title: "Uh oh! Something went wrong.",
70 | description: "There was a problem with your request.",
71 | });
72 | }
73 | setOpen(false);
74 | } catch (error) {
75 | toast({
76 | variant: "destructive",
77 | title: "Uh oh! Something went wrong.",
78 | description: (error as Error).message,
79 | });
80 | }
81 | };
82 |
83 | return (
84 |
176 | );
177 | }
178 |
179 | const timeUnitToSeconds: Record = {
180 | "Second(s)": 1,
181 | "Minute(s)": 60,
182 | "Hour(s)": 3600,
183 | "Day(s)": 86400,
184 | "Week(s)": 604800,
185 | "Month(s)": 2592000, // Note: This is an approximation!
186 | "Year(s)": 31536000, // Note: This does not account for leap years!
187 | };
188 |
189 | function convertToSeconds(expUnit: ExpUnitUnion, exp: number): number {
190 | if (!(expUnit in timeUnitToSeconds)) {
191 | throw new Error("Invalid time unit");
192 | }
193 | return exp * timeUnitToSeconds[expUnit];
194 | }
195 |
--------------------------------------------------------------------------------
| |