├── .editorconfig
├── .eslintignore
├── .eslintrc.js
├── .github
    ├── CODEOWNERS
    └── workflows
    │   └── main.yml
├── .gitignore
├── .husky
    ├── commit-msg
    └── pre-commit
├── .npmrc
├── .prettierignore
├── .prettierrc
├── .releaserc.json
├── CHANGELOG.md
├── LICENSE
├── README.md
├── assets
    └── dashboard.png
├── commitlint.config.js
├── lint-staged.config.js
├── package-lock.json
├── package.config.ts
├── package.json
├── renovate.json
├── sanity.config.ts
├── sanity.json
├── src
    ├── components
    │   ├── DashboardLayout.tsx
    │   ├── DashboardWidgetContainer.tsx
    │   ├── NotFoundWidget.tsx
    │   └── WidgetGroup.tsx
    ├── containers
    │   ├── Dashboard.tsx
    │   ├── DashboardContext.tsx
    │   └── WidgetContainer.tsx
    ├── index.ts
    ├── plugin.tsx
    ├── types.ts
    ├── versionedClient.ts
    └── widgets
    │   ├── projectInfo
    │       ├── ProjectInfo.tsx
    │       ├── index.ts
    │       └── types.ts
    │   ├── projectUsers
    │       ├── ProjectUser.tsx
    │       ├── ProjectUsers.tsx
    │       └── index.ts
    │   └── sanityTutorials
    │       ├── SanityTutorials.tsx
    │       ├── Tutorial.tsx
    │       ├── dataAdapter.ts
    │       └── index.ts
├── tsconfig.json
├── tsconfig.lib.json
├── tsconfig.settings.json
└── v2-incompatible.js
/.editorconfig:
--------------------------------------------------------------------------------
 1 | ; editorconfig.org
 2 | root = true
 3 | charset= utf8
 4 | 
 5 | [*]
 6 | end_of_line = lf
 7 | insert_final_newline = true
 8 | trim_trailing_whitespace = true
 9 | indent_style = space
10 | indent_size = 2
11 | 
12 | [*.md]
13 | trim_trailing_whitespace = false
14 | 
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | *.js
2 | .eslintrc.js
3 | commitlint.config.js
4 | lib
5 | lint-staged.config.js
6 | package.config.ts
7 | sanity.config.ts
8 | 
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
 1 | module.exports = {
 2 |   env: {
 3 |     browser: true,
 4 |     node: false,
 5 |   },
 6 |   extends: [
 7 |     'sanity/react', // must come before sanity/typescript
 8 |     'sanity/typescript',
 9 |     'plugin:prettier/recommended',
10 |     'plugin:react-hooks/recommended',
11 |   ],
12 |   overrides: [
13 |     {
14 |       files: ['*.{ts,tsx}'],
15 |     },
16 |   ],
17 |   parser: '@typescript-eslint/parser',
18 |   parserOptions: {
19 |     ecmaFeatures: {
20 |       jsx: true,
21 |     },
22 |     project: './tsconfig.json',
23 |   },
24 |   plugins: ['prettier'],
25 |   rules: {
26 |     '@typescript-eslint/explicit-module-boundary-types': 'off',
27 |     '@typescript-eslint/explicit-function-return-type': 'off',
28 |     'react/no-unused-prop-types': 'off',
29 |     'react/no-array-index-key': 'off',
30 |     'react/display-name': 0,
31 |     camelcase: 'off',
32 |   },
33 |   settings: {
34 |     'import/ignore': ['\\.css$', '.*node_modules.*', '.*:.*'],
35 |     'import/resolver': {
36 |       node: {
37 |         paths: ['src'],
38 |         extensions: ['.js', '.jsx', '.ts', '.tsx'],
39 |       },
40 |     },
41 |   },
42 | }
43 | 
--------------------------------------------------------------------------------
/.github/CODEOWNERS:
--------------------------------------------------------------------------------
1 | * @sanity-io/ecosystem
2 | 
--------------------------------------------------------------------------------
/.github/workflows/main.yml:
--------------------------------------------------------------------------------
  1 | ---
  2 | name: CI & Release
  3 | 
  4 | # Workflow name based on selected inputs. Fallback to default Github naming when expression evaluates to empty string
  5 | run-name: >-
  6 |   ${{
  7 |     inputs.release && inputs.test && 'Build ➤ Test ➤ Publish to NPM' ||
  8 |     inputs.release && !inputs.test && 'Build ➤ Skip Tests ➤ Publish to NPM' ||
  9 |     github.event_name == 'workflow_dispatch' && inputs.test && 'Build ➤ Test' ||
 10 |     github.event_name == 'workflow_dispatch' && !inputs.test && 'Build ➤ Skip Tests' ||
 11 |     ''
 12 |   }}
 13 | 
 14 | on:
 15 |   # Build on pushes branches that have a PR (including drafts)
 16 |   pull_request:
 17 |   # Build on commits pushed to branches without a PR if it's in the allowlist
 18 |   push:
 19 |     branches: [main]
 20 |   # https://docs.github.com/en/actions/managing-workflow-runs/manually-running-a-workflow
 21 |   workflow_dispatch:
 22 |     inputs:
 23 |       test:
 24 |         description: Run tests
 25 |         required: true
 26 |         default: true
 27 |         type: boolean
 28 |       release:
 29 |         description: Release new version
 30 |         required: true
 31 |         default: false
 32 |         type: boolean
 33 | 
 34 | concurrency:
 35 |   # On PRs builds will cancel if new pushes happen before the CI completes, as it defines `github.head_ref` and gives it the name of the branch the PR wants to merge into
 36 |   # Otherwise `github.run_id` ensures that you can quickly merge a queue of PRs without causing tests to auto cancel on any of the commits pushed to main.
 37 |   group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
 38 |   cancel-in-progress: true
 39 | 
 40 | jobs:
 41 |   log-the-inputs:
 42 |     name: Log inputs
 43 |     runs-on: ubuntu-latest
 44 |     steps:
 45 |       - run: |
 46 |           echo "Inputs: $INPUTS"
 47 |         env:
 48 |           INPUTS: ${{ toJSON(inputs) }}
 49 | 
 50 |   build:
 51 |     runs-on: ubuntu-latest
 52 |     name: Lint & Build
 53 |     steps:
 54 |       - uses: actions/checkout@v4
 55 |       - uses: actions/setup-node@v4
 56 |         with:
 57 |           cache: npm
 58 |           node-version: lts/*
 59 |       - run: npm ci
 60 |         # Linting can be skipped
 61 |       - run: npm run lint --if-present
 62 |         if: github.event.inputs.test != 'false'
 63 |         # But not the build script, as semantic-release will crash if this command fails so it makes sense to test it early
 64 |       - run: npm run prepublishOnly --if-present
 65 | 
 66 |   test:
 67 |     needs: build
 68 |     # The test matrix can be skipped, in case a new release needs to be fast-tracked and tests are already passing on main
 69 |     if: github.event.inputs.test != 'false'
 70 |     runs-on: ${{ matrix.os }}
 71 |     name: Node.js ${{ matrix.node }} / ${{ matrix.os }}
 72 |     strategy:
 73 |       # A test failing on windows doesn't mean it'll fail on macos. It's useful to let all tests run to its completion to get the full picture
 74 |       fail-fast: false
 75 |       matrix:
 76 |         # Run the testing suite on each major OS with the latest LTS release of Node.js
 77 |         os: [macos-latest, ubuntu-latest, windows-latest]
 78 |         node: [lts/*]
 79 |         # It makes sense to also test the oldest, and latest, versions of Node.js, on ubuntu-only since it's the fastest CI runner
 80 |         include:
 81 |           - os: ubuntu-latest
 82 |             # Test the oldest LTS release of Node that's still receiving bugfixes and security patches, versions older than that have reached End-of-Life
 83 |             node: lts/-2
 84 |           - os: ubuntu-latest
 85 |             # Test the actively developed version that will become the latest LTS release next October
 86 |             node: current
 87 |     steps:
 88 |       # It's only necessary to do this for windows, as mac and ubuntu are sane OS's that already use LF
 89 |       - name: Set git to use LF
 90 |         if: matrix.os == 'windows-latest'
 91 |         run: |
 92 |           git config --global core.autocrlf false
 93 |           git config --global core.eol lf
 94 |       - uses: actions/checkout@v4
 95 |       - uses: actions/setup-node@v4
 96 |         with:
 97 |           cache: npm
 98 |           node-version: ${{ matrix.node }}
 99 |       - run: npm i
100 |       - run: npm test --if-present
101 | 
102 |   release:
103 |     needs: [build, test]
104 |     # only run if opt-in during workflow_dispatch
105 |     if: always() && github.event.inputs.release == 'true' && needs.build.result != 'failure' && needs.test.result != 'failure' && needs.test.result != 'cancelled'
106 |     runs-on: ubuntu-latest
107 |     name: Semantic release
108 |     steps:
109 |       - uses: actions/create-github-app-token@v2
110 |         id: app-token
111 |         with:
112 |           app-id: ${{ secrets.ECOSPARK_APP_ID }}
113 |           private-key: ${{ secrets.ECOSPARK_APP_PRIVATE_KEY }}
114 |       - uses: actions/checkout@v4
115 |         with:
116 |           # Need to fetch entire commit history to
117 |           # analyze every commit since last release
118 |           fetch-depth: 0
119 |           # Uses generated token to allow pushing commits back
120 |           token: ${{ steps.app-token.outputs.token }}
121 |           # Make sure the value of GITHUB_TOKEN will not be persisted in repo's config
122 |           persist-credentials: false
123 |       - uses: actions/setup-node@v4
124 |         with:
125 |           cache: npm
126 |           node-version: lts/*
127 |       - run: npm ci
128 |         # Branches that will release new versions are defined in .releaserc.json
129 |       - run: npx semantic-release
130 |         env:
131 |           GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
132 |           NPM_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }}
133 | 
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
 1 | # Logs
 2 | logs
 3 | *.log
 4 | npm-debug.log*
 5 | 
 6 | # Runtime data
 7 | pids
 8 | *.pid
 9 | *.seed
10 | 
11 | # Directory for instrumented libs generated by jscoverage/JSCover
12 | lib-cov
13 | 
14 | # Coverage directory used by tools like istanbul
15 | coverage
16 | 
17 | # nyc test coverage
18 | .nyc_output
19 | 
20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
21 | .grunt
22 | 
23 | # node-waf configuration
24 | .lock-wscript
25 | 
26 | # Compiled binary addons (http://nodejs.org/api/addons.html)
27 | build/Release
28 | 
29 | # Dependency directories
30 | node_modules
31 | jspm_packages
32 | 
33 | # Optional npm cache directory
34 | .npm
35 | 
36 | # Optional REPL history
37 | .node_repl_history
38 | 
39 | # macOS finder cache file
40 | .DS_Store
41 | 
42 | # VS Code settings
43 | .vscode
44 | 
45 | # IntelliJ
46 | .idea
47 | *.iml
48 | 
49 | # Cache
50 | .cache
51 | 
52 | # Yalc
53 | .yalc
54 | yalc.lock
55 | 
56 | # npm package zips
57 | *.tgz
58 | 
59 | # Compiled plugin
60 | lib
61 | 
62 | # Sanity development
63 | .sanity
64 | 
--------------------------------------------------------------------------------
/.husky/commit-msg:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | . "$(dirname "$0")/_/husky.sh"
3 | 
4 | npx --no -- commitlint --edit ""
5 | 
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | . "$(dirname "$0")/_/husky.sh"
3 | 
4 | npx lint-staged
5 | 
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | legacy-peer-deps=true
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | lib
2 | pnpm-lock.yaml
3 | yarn.lock
4 | package-lock.json
5 | 
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 |   "semi": false,
3 |   "printWidth": 100,
4 |   "bracketSpacing": false,
5 |   "singleQuote": true
6 | }
7 | 
--------------------------------------------------------------------------------
/.releaserc.json:
--------------------------------------------------------------------------------
1 | {
2 |   "extends": "@sanity/semantic-release-preset",
3 |   "branches": ["main", {"name": "studio-v2", "channel": "studio-v2", "range": "2.x"}]
4 | }
5 | 
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
  1 | 
  2 | 
  3 | # 📓 Changelog
  4 | 
  5 | All notable changes to this project will be documented in this file. See
  6 | [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
  7 | 
  8 | ## [5.0.0](https://github.com/sanity-io/dashboard/compare/v4.1.4...v5.0.0) (2025-09-15)
  9 | 
 10 | ### ⚠ BREAKING CHANGES
 11 | 
 12 | - **deps:** update @sanity/ui to 3.x (#70)
 13 | 
 14 | ### Features
 15 | 
 16 | - **deps:** update @sanity/ui to 3.x ([#70](https://github.com/sanity-io/dashboard/issues/70)) ([b3862d3](https://github.com/sanity-io/dashboard/commit/b3862d3dec1cdecb4273621d3c38c1a58bb88ce4))
 17 | 
 18 | ## [4.1.4](https://github.com/sanity-io/dashboard/compare/v4.1.3...v4.1.4) (2025-07-10)
 19 | 
 20 | ### Bug Fixes
 21 | 
 22 | - **deps:** allow studio v4 in peer dep ranges ([#67](https://github.com/sanity-io/dashboard/issues/67)) ([4472bf0](https://github.com/sanity-io/dashboard/commit/4472bf0833165b1e8e49f70d3e62d0b98be1e861))
 23 | 
 24 | ## [4.1.3](https://github.com/sanity-io/dashboard/compare/v4.1.2...v4.1.3) (2025-03-10)
 25 | 
 26 | ### Bug Fixes
 27 | 
 28 | - **deps:** bump dependencies for even better react 19 compatibility ([6ad8110](https://github.com/sanity-io/dashboard/commit/6ad811031d3b4bc786a1e62d5c82b01b916d8b38))
 29 | 
 30 | ## [4.1.2](https://github.com/sanity-io/dashboard/compare/v4.1.1...v4.1.2) (2024-12-11)
 31 | 
 32 | ### Bug Fixes
 33 | 
 34 | - **deps:** bump `[@sanity](https://github.com/sanity)` dependencies to react 19-compatible versions ([70442e9](https://github.com/sanity-io/dashboard/commit/70442e91c39ca14d8693f1715e84ea4a2286c0f5))
 35 | 
 36 | ## [4.1.1](https://github.com/sanity-io/dashboard/compare/v4.1.0...v4.1.1) (2024-12-11)
 37 | 
 38 | ### Bug Fixes
 39 | 
 40 | - **deps:** silence audit warnings ([957f4cf](https://github.com/sanity-io/dashboard/commit/957f4cf21f4e4b4534c87d37e3b19d4c2e619f00))
 41 | - **deps:** upgrade sanity dev dependency ([#64](https://github.com/sanity-io/dashboard/issues/64)) ([7b9ff32](https://github.com/sanity-io/dashboard/commit/7b9ff3290b40997539d50f7864a77fdedcbf8762))
 42 | - flag compatibility with React 19 ([f4ef8ed](https://github.com/sanity-io/dashboard/commit/f4ef8ed22dc230f97c050c2fb964f1999fe67816))
 43 | 
 44 | ## [4.1.0](https://github.com/sanity-io/dashboard/compare/v4.0.0...v4.1.0) (2024-10-01)
 45 | 
 46 | ### Features
 47 | 
 48 | - list all studios from user applications list ([#62](https://github.com/sanity-io/dashboard/issues/62)) ([f757d58](https://github.com/sanity-io/dashboard/commit/f757d58b7c7a6b5b61dd4c77de253102f22fda33))
 49 | 
 50 | ### Bug Fixes
 51 | 
 52 | - **deps:** update non-major ([#60](https://github.com/sanity-io/dashboard/issues/60)) ([13b4b5a](https://github.com/sanity-io/dashboard/commit/13b4b5a499e22e54d3162b15757d54b3189f2170))
 53 | 
 54 | ## [4.0.0](https://github.com/sanity-io/dashboard/compare/v3.1.6...v4.0.0) (2024-07-15)
 55 | 
 56 | ### ⚠ BREAKING CHANGES
 57 | 
 58 | - This module now requires the peer dependency `styled-components` greater than or
 59 |   equal to version 6.1. This aligns with Sanity v3.37.0 and higher.
 60 | - This module now requires Node.js 18 or higher.
 61 |   This shouldn't really impact anyone beyond developers of the module, since this really only applies
 62 |   to the build tooling.
 63 | 
 64 | ### Bug Fixes
 65 | 
 66 | - add request tags for all dashboard widget api requests ([528b92d](https://github.com/sanity-io/dashboard/commit/528b92dc2f1869d2d1fcff46000cb1b78aae675f))
 67 | - **projectInfo:** show external studio host if present ([36c6882](https://github.com/sanity-io/dashboard/commit/36c688211145e3dbed283b28f97898d13d6d77ef))
 68 | - **projectUsers:** show all of a users' roles, fix invite link + text ([4699add](https://github.com/sanity-io/dashboard/commit/4699add5706e3381d59c8d70353d30a7ce1b4123))
 69 | - require styled-components ^6.1, node >= 18 ([b0d9cb6](https://github.com/sanity-io/dashboard/commit/b0d9cb6726ec68d97550d1a465196835f463366d))
 70 | - upgrade build tooling, es/cjs export definitions ([21eaa29](https://github.com/sanity-io/dashboard/commit/21eaa29847b7157881d98c171fbaca74865cce17))
 71 | - use named import for styled-components ([5eec15a](https://github.com/sanity-io/dashboard/commit/5eec15ad6a9fdfae4d05b186576459bf302d3898))
 72 | 
 73 | ## [3.1.6](https://github.com/sanity-io/dashboard/compare/v3.1.5...v3.1.6) (2023-11-30)
 74 | 
 75 | ### Bug Fixes
 76 | 
 77 | - **deps:** Update dependency styled-components to v6 ([#41](https://github.com/sanity-io/dashboard/issues/41)) ([4db1ccb](https://github.com/sanity-io/dashboard/commit/4db1ccb64eff362e97c5c21d027f1fec9519f5db))
 78 | 
 79 | ## [3.1.5](https://github.com/sanity-io/dashboard/compare/v3.1.4...v3.1.5) (2023-08-02)
 80 | 
 81 | ### Bug Fixes
 82 | 
 83 | - **deps:** update dependencies (non-major) ([#18](https://github.com/sanity-io/dashboard/issues/18)) ([657bcfc](https://github.com/sanity-io/dashboard/commit/657bcfc631355b5f53727998e6d8ab75539ce577))
 84 | 
 85 | ## [3.1.4](https://github.com/sanity-io/dashboard/compare/v3.1.3...v3.1.4) (2023-05-03)
 86 | 
 87 | ### Bug Fixes
 88 | 
 89 | - **docs:** Update README.md ([#31](https://github.com/sanity-io/dashboard/issues/31)) ([c7450b9](https://github.com/sanity-io/dashboard/commit/c7450b98f417ed3f09e1f1a915ca59f082b0106a))
 90 | 
 91 | ## [3.1.3](https://github.com/sanity-io/dashboard/compare/v3.1.2...v3.1.3) (2023-01-31)
 92 | 
 93 | ### Bug Fixes
 94 | 
 95 | - **docs:** add instructions on customizing name, title, icon ([e7bb30b](https://github.com/sanity-io/dashboard/commit/e7bb30b34402d216d53a54fa65a37e098300fc6c))
 96 | 
 97 | ## [3.1.2](https://github.com/sanity-io/dashboard/compare/v3.1.1...v3.1.2) (2023-01-04)
 98 | 
 99 | ### Bug Fixes
100 | 
101 | - **deps:** applied npx @sanity/plugin-kit inject ([300067e](https://github.com/sanity-io/dashboard/commit/300067e12549d04817d1dae24a61992b57a426fa))
102 | 
103 | ## [3.1.1](https://github.com/sanity-io/dashboard/compare/v3.1.0...v3.1.1) (2022-12-22)
104 | 
105 | ### Bug Fixes
106 | 
107 | - **ui:** dashboard content overflowed layout ([0aa8cbe](https://github.com/sanity-io/dashboard/commit/0aa8cbed0d4775d667d51c86ea61e645c89c1b9a))
108 | 
109 | ## [3.1.0](https://github.com/sanity-io/dashboard/compare/v3.0.0...v3.1.0) (2022-12-22)
110 | 
111 | ### Features
112 | 
113 | - make name and icon configurable ([6db4c65](https://github.com/sanity-io/dashboard/commit/6db4c6573d558881621b764a4c124a431a1071d8))
114 | - make title configurable ([36fcaf8](https://github.com/sanity-io/dashboard/commit/36fcaf8fa8274aa8724a2bd6ae33c0b50e5bfd6e))
115 | 
116 | ## [3.0.0](https://github.com/sanity-io/dashboard/compare/v2.35.2...v3.0.0) (2022-11-25)
117 | 
118 | ### ⚠ BREAKING CHANGES
119 | 
120 | - this version does not work in Sanity Studio v2
121 | - this version does not work in Sanity Studio v2
122 | - semantic-release is being difficult
123 | 
124 | ### Features
125 | 
126 | - dummy breaking to trick semantic-release ([53dd9dc](https://github.com/sanity-io/dashboard/commit/53dd9dcae19e2d6db97e11302867c3838ff155c6))
127 | - initial release for Sanity Studio v3 ([4e3db99](https://github.com/sanity-io/dashboard/commit/4e3db99e83e49c5876db83c3fc3fe0ff5c3d3725))
128 | - initial Sanity Studio v3 release ([9ea2f0a](https://github.com/sanity-io/dashboard/commit/9ea2f0a7146464f197598a63336f2500ff836aae))
129 | 
130 | ### Bug Fixes
131 | 
132 | - compiled for dev-preview.22 ([3b97135](https://github.com/sanity-io/dashboard/commit/3b97135143ee29f2e1c2bae6f8e6ae051a943d4b))
133 | - compiled for sanity 3.0.0-rc.0 ([74dfd9a](https://github.com/sanity-io/dashboard/commit/74dfd9a3db922649f32e52f990a80c5de7d1a752))
134 | - **deps:** dev-preview.21 ([730cc2a](https://github.com/sanity-io/dashboard/commit/730cc2a25a36f57e5c3310079e262b39d8412774))
135 | - **deps:** pin dependencies ([5fc3f9d](https://github.com/sanity-io/dashboard/commit/5fc3f9d276116dffcc957ed39b3a3e876a479fbf))
136 | - **deps:** pkg-utils & @sanity/plugin-kit ([7d63c7c](https://github.com/sanity-io/dashboard/commit/7d63c7c05d274ab5d26fefd3da7807264f17b468))
137 | - **deps:** sanity ^3.0.0 (works with rc.3) ([eeb0c7c](https://github.com/sanity-io/dashboard/commit/eeb0c7cdfd628ead3dcab39f7ce3cb2df2f1f784))
138 | - **deps:** sanity 3.0.0-dev-preview.17 ([bed90ee](https://github.com/sanity-io/dashboard/commit/bed90eeaa3c6d2f9a8cda3a9c597e792d2816cff))
139 | - **deps:** update dependency @sanity/icons to v1.3.9-beta.3 ([#12](https://github.com/sanity-io/dashboard/issues/12)) ([b547871](https://github.com/sanity-io/dashboard/commit/b5478710f11d58d898e1eaf638b702dece12edaa))
140 | - **deps:** update dependency rxjs to ^6.6.7 ([#4](https://github.com/sanity-io/dashboard/issues/4)) ([b79f4be](https://github.com/sanity-io/dashboard/commit/b79f4bec0661c32f8b1a797f82ea22195ec583d9))
141 | - **deps:** updated deps and added semver workflow ([68d714e](https://github.com/sanity-io/dashboard/commit/68d714e2b3457fbd4ab112a7cbc6194057b61e36))
142 | 
143 | ## [3.0.0-v3-studio.8](https://github.com/sanity-io/dashboard/compare/v3.0.0-v3-studio.7...v3.0.0-v3-studio.8) (2022-11-04)
144 | 
145 | ### Bug Fixes
146 | 
147 | - **deps:** pkg-utils & @sanity/plugin-kit ([7d63c7c](https://github.com/sanity-io/dashboard/commit/7d63c7c05d274ab5d26fefd3da7807264f17b468))
148 | 
149 | ## [3.0.0-v3-studio.7](https://github.com/sanity-io/dashboard/compare/v3.0.0-v3-studio.6...v3.0.0-v3-studio.7) (2022-11-04)
150 | 
151 | ### Bug Fixes
152 | 
153 | - **deps:** pin dependencies ([5fc3f9d](https://github.com/sanity-io/dashboard/commit/5fc3f9d276116dffcc957ed39b3a3e876a479fbf))
154 | - **deps:** update dependency @sanity/icons to v1.3.9-beta.3 ([#12](https://github.com/sanity-io/dashboard/issues/12)) ([b547871](https://github.com/sanity-io/dashboard/commit/b5478710f11d58d898e1eaf638b702dece12edaa))
155 | - **deps:** update dependency rxjs to ^6.6.7 ([#4](https://github.com/sanity-io/dashboard/issues/4)) ([b79f4be](https://github.com/sanity-io/dashboard/commit/b79f4bec0661c32f8b1a797f82ea22195ec583d9))
156 | 
157 | ## [3.0.0-v3-studio.6](https://github.com/sanity-io/dashboard/compare/v3.0.0-v3-studio.5...v3.0.0-v3-studio.6) (2022-11-02)
158 | 
159 | ### Bug Fixes
160 | 
161 | - compiled for sanity 3.0.0-rc.0 ([74dfd9a](https://github.com/sanity-io/dashboard/commit/74dfd9a3db922649f32e52f990a80c5de7d1a752))
162 | 
163 | ## [3.0.0-v3-studio.5](https://github.com/sanity-io/dashboard/compare/v3.0.0-v3-studio.4...v3.0.0-v3-studio.5) (2022-10-27)
164 | 
165 | ### Bug Fixes
166 | 
167 | - compiled for dev-preview.22 ([3b97135](https://github.com/sanity-io/dashboard/commit/3b97135143ee29f2e1c2bae6f8e6ae051a943d4b))
168 | 
169 | ## [3.0.0-v3-studio.4](https://github.com/sanity-io/dashboard/compare/v3.0.0-v3-studio.3...v3.0.0-v3-studio.4) (2022-10-07)
170 | 
171 | ### Bug Fixes
172 | 
173 | - **deps:** dev-preview.21 ([730cc2a](https://github.com/sanity-io/dashboard/commit/730cc2a25a36f57e5c3310079e262b39d8412774))
174 | 
175 | ## [3.0.0-v3-studio.3](https://github.com/sanity-io/dashboard/compare/v3.0.0-v3-studio.2...v3.0.0-v3-studio.3) (2022-09-15)
176 | 
177 | ### Bug Fixes
178 | 
179 | - **deps:** sanity 3.0.0-dev-preview.17 ([bed90ee](https://github.com/sanity-io/dashboard/commit/bed90eeaa3c6d2f9a8cda3a9c597e792d2816cff))
180 | 
181 | ## [3.0.0-v3-studio.2](https://github.com/sanity-io/dashboard/compare/v3.0.0-v3-studio.1...v3.0.0-v3-studio.2) (2022-09-14)
182 | 
183 | ### ⚠ BREAKING CHANGES
184 | 
185 | - semantic-release is being difficult
186 | 
187 | ### Features
188 | 
189 | - dummy breaking to trick semantic-release ([53dd9dc](https://github.com/sanity-io/dashboard/commit/53dd9dcae19e2d6db97e11302867c3838ff155c6))
190 | 
191 | ### Bug Fixes
192 | 
193 | - **deps:** updated deps and added semver workflow ([68d714e](https://github.com/sanity-io/dashboard/commit/68d714e2b3457fbd4ab112a7cbc6194057b61e36))
194 | 
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
 1 | MIT License
 2 | 
 3 | Copyright (c) 2025 Sanity.io
 4 | 
 5 | Permission is hereby granted, free of charge, to any person obtaining a copy
 6 | of this software and associated documentation files (the "Software"), to deal
 7 | in the Software without restriction, including without limitation the rights
 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 | 
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 | 
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 | 
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
  1 | Sanity Dashboard
  2 | 
  3 | > For the v2 version, please refer to the [v2-branch](https://github.com/sanity-io/dashboard/tree/studio-v2).
  4 | 
  5 | ## What is it?
  6 | 
  7 | Dashboard is a Sanity Content Studio Tool which renders any widgets configured for it.
  8 | Install this plugin in your Studio to display stats about your project, recently edited documents, etc.
  9 | 
 10 | The Dashboard tool has been designed to be as generic as possible, making few assumptions about its widgets.
 11 | The Dashboard itself is mostly concerned about the layout of the configured widgets.
 12 | 
 13 | 
 14 | 
 15 | ## Install
 16 | 
 17 | In your Sanity Content Studio run:
 18 | 
 19 | `npm install --save @sanity/dashboard`
 20 | 
 21 | or
 22 | 
 23 | `yarn add @sanity/dashboard`
 24 | 
 25 | ## Basic usage
 26 | 
 27 | In `sanity.config.js` (or .ts), add the dashboard tool to the defineConfig plugins array:
 28 | 
 29 | ```ts
 30 | import {defineConfig} from 'sanity'
 31 | import {dashboardTool} from '@sanity/dashboard'
 32 | export default defineConfig({
 33 |   /* ... */
 34 |   plugins: [dashboardTool({widgets: []})],
 35 | })
 36 | ```
 37 | 
 38 | To verify that all is well, fire up your Studio (`sanity start`) and point your browser to `http://localhost:3333/dashboard`.
 39 | It should show an empty dashboard, with a message encouraging you to add some widgets to the dashboard.
 40 | 
 41 | ## How to configure the Dashboard
 42 | 
 43 | Now, add any widgets you might want. The dashboard plugin provides three widgets out-of-the-box:
 44 | 
 45 | ```ts
 46 | import {defineConfig} from 'sanity'
 47 | import {
 48 |   dashboardTool,
 49 |   sanityTutorialsWidget,
 50 |   projectUsersWidget,
 51 |   projectInfoWidget,
 52 | } from '@sanity/dashboard'
 53 | 
 54 | // configure the dashboard tool with widgets
 55 | dashboardTool({
 56 |   widgets: [sanityTutorialsWidget(), projectInfoWidget(), projectUsersWidget()],
 57 | })
 58 | ```
 59 | 
 60 | Widgets can be configured by passing widget-specific config:
 61 | 
 62 | ```ts
 63 | projectUsersWidget({layout: 'medium'})
 64 | ```
 65 | 
 66 | You can change the name, title and icon of the dashboard tool should you want to - which also allows you to configure multiple dashboards with different configurations:
 67 | 
 68 | ```ts
 69 | import {defineConfig} from 'sanity'
 70 | import {dashboardTool} from '@sanity/dashboard'
 71 | import {ActivityIcon} from '@sanity/icons'
 72 | 
 73 | dashboardTool({
 74 |   name: 'stats',
 75 |   title: 'Statistics',
 76 |   icon: ActivityIcon,
 77 |   widgets: [
 78 |     /* ... */
 79 |   ],
 80 | })
 81 | ```
 82 | 
 83 | ## How to install a widget
 84 | 
 85 | Install a Dashboard widget as you would any npm package.
 86 | 
 87 | E.g. if you want to install the cats example widget mentioned below, proceed as follows:
 88 | 
 89 | 1. Run `yarn install @sanity/sanity-plugin-dashboard-widget-cats` in the studio directory
 90 | 2. Update your `sanity.config.js` by importing the widget and adding it to the widget array.
 91 | 3. You've got 🐱 in your Studio
 92 | 
 93 | Some widgets have widget-specific options to change aspects of their behavior.
 94 | If you install the `@sanity/sanity-plugin-dashboard-widget-document-list` widget mentioned below,
 95 | it can be configured with:
 96 | 
 97 | ```ts
 98 | documentListWidget({
 99 |   showCreateButton: true,
100 |   limit: 5,
101 |   types: ['my-document-type'],
102 | })
103 | ```
104 | 
105 | You can add multiple instances of a widget with different configuration.
106 | So, if you want your dashboard to display both newest documents across all document types and
107 | another widget showing the last edited books, dashboard config could look like this:
108 | 
109 | ```js
110 | export default {
111 |   widgets: [
112 |     documentListWidget({title: 'New', order: '_createdAt desc'}),
113 |     documentListWidget({title: 'Last edited books', order: '_updatedAt desc', types: ['book']}),
114 |   ],
115 | }
116 | ```
117 | 
118 | ## How to create a widget
119 | 
120 | Widgets are simply objects that follow implement the [DashboardWidget](src/types.ts) interface.
121 | Let's have a look at some sample widgets:
122 | 
123 | For example, [a document list](https://github.com/sanity-io/dashboard-widget-document-list/tree/master) or
124 | [maybe some cats](https://github.com/sanity-io/example-dashboard-widget-cats)?
125 | 
126 | When writing your widget components, it's recommended to use the `DashboardWidgetContainer` component from
127 | this package by importing it like so:
128 | `import { DashboardWidgetContainer } from "@sanity/dashboard";`.
129 | 
130 | This gives you a typical widget component structure with basic styles,
131 | and the option of presenting your content in the header, footer, or body of the widget.
132 | 
133 | If you need something more flexible you can create your own component.
134 | 
135 | Setting up the widget with the default setup will give you a basic widget that looks something like this:
136 | 
137 | ```js
138 | 
142 |       
143 |     
144 |   }
145 | >
146 |   
147 |      148 |   
149 | 
150 | ```
151 | 
152 | ### More examples
153 | 
154 | You can study the source code of these widgets to get a sense of how you can approach fetching of documents, adding configuration, and so on:
155 | 
156 | - [dashboard-widget-document-list](https://github.com/sanity-io/dashboard-widget-document-list)
157 | - [dashboard-widget-widget-document-count](https://github.com/sanity-io/example-dashboard-widget-document-count)
158 | - [dashboard-widget-netlify](https://github.com/sanity-io/sanity-plugin-dashboard-widget-netlify)
159 | 
160 | ---
161 | 
162 | ### Upgrading from v2
163 | 
164 | If you were previously using @sanity/dashboard in a v2 Sanity Studio, will have to make the following changes:
165 | 
166 | - Install the v3 version of @sanity/dashboard in the Studio
167 | - Install v3 versions of any widgets
168 | - Configure the dashboard as described above:
169 |   - Add dashboardTool to plugins array
170 |   - Add widgets to widgets configuration
171 |   - Move any config you had in v2 `dashboardConfiguration.js` on a widget-by-widget basis.
172 |   - V2 used an options-object to pass widget-specific configuration. In v3, options have been replaced by
173 |     passing the same configuration directly to the widget-function.
174 | - Custom widget components should import DashboardWidgetContainer instead of DashboardWidget
175 | 
176 | ## Develop & test
177 | 
178 | This plugin uses [@sanity/plugin-kit](https://github.com/sanity-io/plugin-kit)
179 | with default configuration for build & watch scripts.
180 | 
181 | See [Testing a plugin in Sanity Studio](https://github.com/sanity-io/plugin-kit#testing-a-plugin-in-sanity-studio)
182 | on how to run this plugin with hotreload in the studio.
183 | 
184 | ### Release new version
185 | 
186 | Run ["CI & Release" workflow](https://github.com/sanity-io/dashboard/actions/workflows/main.yml).
187 | Make sure to select the main branch and check "Release new version".
188 | 
189 | Semantic release will only release on configured branches, so it is safe to run release on any branch.
190 | 
191 | ## License
192 | 
193 | MIT-licensed. See LICENSE.
194 | 
195 | ## Develop & test
196 | 
197 | This plugin uses [@sanity/plugin-kit](https://github.com/sanity-io/plugin-kit)
198 | with default configuration for build & watch scripts.
199 | 
200 | See [Testing a plugin in Sanity Studio](https://github.com/sanity-io/plugin-kit#testing-a-plugin-in-sanity-studio)
201 | on how to run this plugin with hotreload in the studio.
202 | 
203 | ### Release new version
204 | 
205 | Run ["CI & Release" workflow](https://github.com/sanity-io/dashboard/actions/workflows/main.yml).
206 | Make sure to select the main branch and check "Release new version".
207 | 
208 | Semantic release will only release on configured branches, so it is safe to run release on any branch.
209 | 
--------------------------------------------------------------------------------
/assets/dashboard.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sanity-io/dashboard/fed22f592ea43913d359acdb4a3bacc0452c4873/assets/dashboard.png
--------------------------------------------------------------------------------
/commitlint.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 |   extends: ['@commitlint/config-conventional'],
3 | }
4 | 
--------------------------------------------------------------------------------
/lint-staged.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 |   '**/*.{js,jsx}': ['eslint'],
3 |   '**/*.{ts,tsx}': ['eslint', () => 'tsc --noEmit'],
4 | }
5 | 
--------------------------------------------------------------------------------
/package.config.ts:
--------------------------------------------------------------------------------
 1 | import {defineConfig} from '@sanity/pkg-utils'
 2 | 
 3 | export default defineConfig({
 4 |   legacyExports: true,
 5 |   dist: 'lib',
 6 |   tsconfig: 'tsconfig.lib.json',
 7 | 
 8 |   // Remove this block to enable strict export validation
 9 |   extract: {
10 |     rules: {
11 |       'ae-forgotten-export': 'off',
12 |       'ae-incompatible-release-tags': 'off',
13 |       'ae-internal-missing-underscore': 'off',
14 |       'ae-missing-release-tag': 'off',
15 |     },
16 |   },
17 | })
18 | 
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
  1 | {
  2 |   "name": "@sanity/dashboard",
  3 |   "version": "5.0.0",
  4 |   "description": "Tool for rendering dashboard widgets",
  5 |   "keywords": [
  6 |     "sanity",
  7 |     "cms",
  8 |     "headless",
  9 |     "realtime",
 10 |     "content",
 11 |     "dashboard",
 12 |     "sanity-plugin",
 13 |     "sanity-tool"
 14 |   ],
 15 |   "homepage": "https://github.com/sanity-io/dashboard#readme",
 16 |   "bugs": {
 17 |     "url": "https://github.com/sanity-io/dashboard/issues"
 18 |   },
 19 |   "repository": {
 20 |     "type": "git",
 21 |     "url": "git@github.com:sanity-io/dashboard.git"
 22 |   },
 23 |   "license": "MIT",
 24 |   "author": "Sanity.io ",
 25 |   "sideEffects": false,
 26 |   "exports": {
 27 |     ".": {
 28 |       "source": "./src/index.ts",
 29 |       "import": "./lib/index.mjs",
 30 |       "require": "./lib/index.js",
 31 |       "default": "./lib/index.mjs"
 32 |     },
 33 |     "./package.json": "./package.json"
 34 |   },
 35 |   "main": "./lib/index.js",
 36 |   "module": "./lib/index.esm.js",
 37 |   "types": "./lib/index.d.ts",
 38 |   "files": [
 39 |     "lib",
 40 |     "sanity.json",
 41 |     "src",
 42 |     "v2-incompatible.js"
 43 |   ],
 44 |   "scripts": {
 45 |     "prebuild": "npm run clean && plugin-kit verify-package --silent && pkg-utils",
 46 |     "build": "plugin-kit verify-package --silent && pkg-utils build --strict --check --clean",
 47 |     "clean": "rimraf lib",
 48 |     "compile": "tsc --noEmit",
 49 |     "dev": "sanity dev",
 50 |     "format": "prettier --write --cache --ignore-unknown .",
 51 |     "link-watch": "plugin-kit link-watch",
 52 |     "lint": "eslint .",
 53 |     "prepare": "husky install",
 54 |     "prepublishOnly": "npm run build",
 55 |     "watch": "pkg-utils watch --strict"
 56 |   },
 57 |   "browserslist": "extends @sanity/browserslist-config",
 58 |   "dependencies": {
 59 |     "@sanity/icons": "^3.5.2",
 60 |     "@sanity/image-url": "^1.1.0",
 61 |     "@sanity/incompatible-plugin": "^1.0.4",
 62 |     "@sanity/ui": "^3.1.0",
 63 |     "lodash": "^4.17.21",
 64 |     "rxjs": "^7.8.1"
 65 |   },
 66 |   "devDependencies": {
 67 |     "@commitlint/cli": "^19.8.0",
 68 |     "@commitlint/config-conventional": "^19.8.0",
 69 |     "@sanity/pkg-utils": "^6.13.4",
 70 |     "@sanity/plugin-kit": "^4.0.19",
 71 |     "@sanity/semantic-release-preset": "^5.0.0",
 72 |     "@types/react": "^18.3.18",
 73 |     "@typescript-eslint/eslint-plugin": "^7.18.0",
 74 |     "@typescript-eslint/parser": "^7.18.0",
 75 |     "eslint": "^8.57.1",
 76 |     "eslint-config-prettier": "^9.1.0",
 77 |     "eslint-config-sanity": "^6.0.0",
 78 |     "eslint-plugin-prettier": "^5.2.3",
 79 |     "eslint-plugin-react": "^7.37.4",
 80 |     "eslint-plugin-react-hooks": "^4.6.2",
 81 |     "husky": "^8.0.3",
 82 |     "lint-staged": "^15.2.11",
 83 |     "npm-run-all2": "^5.0.2",
 84 |     "prettier": "^3.5.3",
 85 |     "prettier-plugin-packagejson": "^2.5.10",
 86 |     "react": "^18.3.1",
 87 |     "react-dom": "^18.3.1",
 88 |     "react-is": "^18.3.1",
 89 |     "rimraf": "^6.0.0",
 90 |     "sanity": "^3.78.1",
 91 |     "semantic-release": "^24.2.3",
 92 |     "styled-components": "^6.1.15",
 93 |     "typescript": "^5.7.3"
 94 |   },
 95 |   "peerDependencies": {
 96 |     "react": "^18 || >=19.0.0-0",
 97 |     "sanity": "^3 || ^4.0.0-0",
 98 |     "styled-components": "^6.1"
 99 |   },
100 |   "engines": {
101 |     "node": ">=18"
102 |   },
103 |   "overrides": {
104 |     "conventional-changelog-conventionalcommits": ">= 8.0.0",
105 |     "cross-spawn": "^7.0.6"
106 |   },
107 |   "sanityExchangeUrl": "https://www.sanity.io/exchange/dashboard"
108 | }
109 | 
--------------------------------------------------------------------------------
/renovate.json:
--------------------------------------------------------------------------------
1 | {
2 |   "$schema": "https://docs.renovatebot.com/renovate-schema.json",
3 |   "extends": [
4 |     "github>sanity-io/renovate-presets//ecosystem/auto",
5 |     "github>sanity-io/renovate-presets//ecosystem/studio-v3"
6 |   ]
7 | }
8 | 
--------------------------------------------------------------------------------
/sanity.config.ts:
--------------------------------------------------------------------------------
 1 | import {defineConfig} from 'sanity'
 2 | import {
 3 |   dashboardTool,
 4 |   projectInfoWidget,
 5 |   projectUsersWidget,
 6 |   sanityTutorialsWidget,
 7 | } from './src/index'
 8 | 
 9 | export default defineConfig({
10 |   projectId: 'ppsg7ml5',
11 |   dataset: 'test',
12 |   plugins: [
13 |     dashboardTool({
14 |       widgets: [sanityTutorialsWidget(), projectUsersWidget(), projectInfoWidget()],
15 |     }),
16 |   ],
17 |   scheduledPublishing: {
18 |     enabled: false,
19 |   },
20 |   tasks: {
21 |     enabled: false,
22 |   },
23 | })
24 | 
--------------------------------------------------------------------------------
/sanity.json:
--------------------------------------------------------------------------------
1 | {
2 |   "parts": [
3 |     {
4 |       "implements": "part:@sanity/base/sanity-root",
5 |       "path": "./v2-incompatible.js"
6 |     }
7 |   ]
8 | }
9 | 
--------------------------------------------------------------------------------
/src/components/DashboardLayout.tsx:
--------------------------------------------------------------------------------
 1 | import React, {PropsWithChildren} from 'react'
 2 | import {Container} from '@sanity/ui'
 3 | 
 4 | export function DashboardLayout(props: PropsWithChildren<{}>) {
 5 |   return (
 6 |     
 7 |       {props.children}
 8 |     
 9 |   )
10 | }
11 | 
--------------------------------------------------------------------------------
/src/components/DashboardWidgetContainer.tsx:
--------------------------------------------------------------------------------
 1 | import React, {forwardRef} from 'react'
 2 | import {Card, Box, Heading} from '@sanity/ui'
 3 | import {styled} from 'styled-components'
 4 | 
 5 | const Root = styled(Card)`
 6 |   display: flex;
 7 |   flex-direction: column;
 8 |   justify-content: stretch;
 9 |   height: 100%;
10 |   box-sizing: border-box;
11 |   position: relative;
12 | `
13 | 
14 | const Header = styled(Card)`
15 |   position: sticky;
16 |   top: 0;
17 |   z-index: 2;
18 |   border-top-left-radius: inherit;
19 |   border-top-right-radius: inherit;
20 | `
21 | 
22 | const Footer = styled(Card)`
23 |   position: sticky;
24 |   overflow: hidden;
25 |   bottom: 0;
26 |   z-index: 2;
27 |   border-bottom-right-radius: inherit;
28 |   border-bottom-left-radius: inherit;
29 |   margin-top: auto;
30 | `
31 | 
32 | const Content = styled(Box)`
33 |   position: relative;
34 |   z-index: 1;
35 |   height: stretch;
36 |   min-height: 21.5em;
37 | 
38 |   @media (min-width: ${({theme}) => theme.sanity.media[0]}px) {
39 |     overflow-y: auto;
40 |     outline: none;
41 |   }
42 | `
43 | 
44 | interface DashboardWidgetProps {
45 |   header?: string
46 |   children: React.ReactNode
47 |   footer?: React.ReactNode
48 | }
49 | 
50 | export const DashboardWidgetContainer = forwardRef(function DashboardWidgetContainer(
51 |   props: DashboardWidgetProps,
52 |   ref: React.Ref,
53 | ) {
54 |   const {header, children, footer} = props
55 | 
56 |   return (
57 |     
58 |       {header && (
59 |         
60 |           
61 |             {header}
62 |           
63 |         
64 |       )}
65 |       {children && {children}}
66 |       {footer && }
67 |     
68 |   )
69 | })
70 | 
--------------------------------------------------------------------------------
/src/components/NotFoundWidget.tsx:
--------------------------------------------------------------------------------
 1 | import React, {PropsWithChildren, ReactNode} from 'react'
 2 | import {Card, Stack, Heading, Box} from '@sanity/ui'
 3 | import {styled} from 'styled-components'
 4 | 
 5 | const Root = styled(Card)`
 6 |   display: flex;
 7 |   flex-direction: column;
 8 |   justify-content: stretch;
 9 |   height: 100%;
10 | `
11 | 
12 | export type NotFoundWidgetProps = PropsWithChildren<{
13 |   title?: ReactNode
14 | }>
15 | 
16 | export function NotFoundWidget(props: NotFoundWidgetProps) {
17 |   const {title, children} = props
18 |   return (
19 |     
20 |       
21 |         {title && (
22 |           
23 |             {title}
24 |           
25 |         )}
26 |         {children && {children}}
27 |       
28 |     
29 |   )
30 | }
31 | 
--------------------------------------------------------------------------------
/src/components/WidgetGroup.tsx:
--------------------------------------------------------------------------------
  1 | import React from 'react'
  2 | import {styled, css} from 'styled-components'
  3 | import {Box, Card, Grid, Text} from '@sanity/ui'
  4 | import {WidgetContainer} from '../containers/WidgetContainer'
  5 | import {DashboardConfig, LayoutConfig, DashboardWidget} from '../types'
  6 | 
  7 | const media = {
  8 |   small: (...args: Parameters) => css`
  9 |     @media (min-width: ${({theme}) => theme.sanity.media[0]}px) {
 10 |       ${css(...args)}
 11 |     }
 12 |   `,
 13 |   medium: (...args: Parameters) => css`
 14 |     @media (min-width: ${({theme}) => theme.sanity.media[2]}px) {
 15 |       ${css(...args)}
 16 |     }
 17 |   `,
 18 | }
 19 | 
 20 | const Root = styled(Grid)`
 21 |   grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
 22 | 
 23 |   & > div {
 24 |     overflow: hidden;
 25 |   }
 26 | 
 27 |   & > div[data-width='medium'] {
 28 |     ${media.small`
 29 |       grid-column: span 2;
 30 |     `}
 31 |   }
 32 | 
 33 |   & > div[data-width='large'] {
 34 |     ${media.small`
 35 |       grid-column: span 2;
 36 |     `}
 37 | 
 38 |     ${media.medium`
 39 |       grid-column: span 3;
 40 |     `}
 41 |   }
 42 | 
 43 |   & > div[data-width='full'] {
 44 |     ${media.small`
 45 |       grid-column: 1 / -1;
 46 |     `}
 47 |   }
 48 | 
 49 |   & > div[data-height='medium'] {
 50 |     ${media.small`
 51 |       grid-row: span 2;
 52 |     `}
 53 |   }
 54 | 
 55 |   & > div[data-height='large'] {
 56 |     ${media.small`
 57 |       grid-row: span 2;
 58 |     `}
 59 | 
 60 |     ${media.medium`
 61 |       grid-row: span 3;
 62 |     `}
 63 |   }
 64 | 
 65 |   & > div[data-height='full'] {
 66 |     ${media.medium`
 67 |       grid-row: 1 / -1;
 68 |     `}
 69 |   }
 70 | `
 71 | 
 72 | export interface WidgetGroupProps {
 73 |   config: Partial
 74 | }
 75 | 
 76 | const NO_WIDGETS: DashboardWidget[] = []
 77 | const NO_LAYOUT: LayoutConfig = {}
 78 | 
 79 | export function WidgetGroup(props: WidgetGroupProps) {
 80 |   const {
 81 |     config: {layout = NO_LAYOUT, widgets = NO_WIDGETS},
 82 |   } = props
 83 |   return (
 84 |     
 90 |       {widgets.length ? null : (
 91 |         
 92 |           Add some widgets to populate this space.
 93 |         
 94 |       )}
 95 |       {widgets.map((widgetConfig, index) => {
 96 |         if (widgetConfig.type === '__experimental_group') {
 97 |           return 
 98 |         }
 99 |         if (widgetConfig.component) {
100 |           return 
101 |         }
102 |         return {widgetConfig.name} is missing widget component
103 |       })}
104 |     
105 |   )
106 | }
107 | 
--------------------------------------------------------------------------------
/src/containers/Dashboard.tsx:
--------------------------------------------------------------------------------
 1 | import React from 'react'
 2 | import {DashboardLayout} from '../components/DashboardLayout'
 3 | import {WidgetGroup} from '../components/WidgetGroup'
 4 | import {DashboardContext} from './DashboardContext'
 5 | import {DashboardConfig} from '../types'
 6 | 
 7 | export function Dashboard({config}: {config: DashboardConfig}) {
 8 |   if (!config) {
 9 |     return null
10 |   }
11 | 
12 |   return (
13 |     
14 |       
15 |         
16 |       
17 |     
18 |   )
19 | }
20 | 
--------------------------------------------------------------------------------
/src/containers/DashboardContext.tsx:
--------------------------------------------------------------------------------
1 | import {createContext, useContext} from 'react'
2 | import {DashboardConfig} from '../types'
3 | 
4 | export const DashboardContext = createContext({widgets: []})
5 | 
6 | export function useDashboardConfig(): DashboardConfig {
7 |   return useContext(DashboardContext)
8 | }
9 | 
--------------------------------------------------------------------------------
/src/containers/WidgetContainer.tsx:
--------------------------------------------------------------------------------
 1 | import React, {createElement, useMemo} from 'react'
 2 | import {useDashboardConfig} from './DashboardContext'
 3 | import {Card} from '@sanity/ui'
 4 | import {DashboardWidget} from '../types'
 5 | 
 6 | export function WidgetContainer(props: DashboardWidget) {
 7 |   const config = useDashboardConfig()
 8 |   const layout = useMemo(
 9 |     () => ({
10 |       ...(props.layout || {}),
11 |       ...(config.layout || {}),
12 |     }),
13 |     [props.layout, config.layout],
14 |   )
15 | 
16 |   return (
17 |     
18 |       {createElement(props.component, {})}
19 |     
20 |   )
21 | }
22 | 
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | export * from './types'
2 | export * from './components/DashboardWidgetContainer'
3 | export * from './widgets/projectInfo'
4 | export * from './widgets/projectUsers'
5 | export * from './widgets/sanityTutorials'
6 | 
7 | export {type DashboardPluginConfig, dashboardTool} from './plugin'
8 | 
--------------------------------------------------------------------------------
/src/plugin.tsx:
--------------------------------------------------------------------------------
 1 | import React, {ComponentType, CSSProperties} from 'react'
 2 | import {Dashboard} from './containers/Dashboard'
 3 | import {definePlugin} from 'sanity'
 4 | import {DashboardConfig, DashboardWidget, LayoutConfig} from './types'
 5 | 
 6 | const strokeStyle: CSSProperties = {
 7 |   stroke: 'currentColor',
 8 |   strokeWidth: 1.2,
 9 | }
10 | 
11 | const DashboardIcon = () => (
12 |   
25 | )
26 | 
27 | export interface DashboardPluginConfig {
28 |   /**
29 |    * Dashboard tool title
30 |    */
31 |   title?: string
32 |   /**
33 |    * Dashboard tool name (used in url path)
34 |    */
35 |   name?: string
36 |   /**
37 |    * Dashboard tool icon
38 |    */
39 |   icon?: ComponentType
40 |   widgets?: DashboardWidget[]
41 | 
42 |   /**
43 |    * Will be used for widgets that do not define a layout directly.
44 |    */
45 |   defaultLayout?: LayoutConfig
46 | }
47 | 
48 | export const dashboardTool = definePlugin((config = {}) => {
49 |   const pluginConfig: DashboardConfig = {
50 |     layout: config.defaultLayout ?? {},
51 |     widgets: config.widgets ?? [],
52 |   }
53 | 
54 |   const title = config.title ?? 'Dashboard'
55 |   const name = config.name ?? 'dashboard'
56 |   const icon = config.icon ?? DashboardIcon
57 | 
58 |   return {
59 |     name: 'dashboard',
60 |     tools: (prev, context) => {
61 |       return [
62 |         ...prev,
63 |         {
64 |           title,
65 |           name,
66 |           icon,
67 |           component: () => ,
68 |         },
69 |       ]
70 |     },
71 |   }
72 | })
73 | 
--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------
 1 | import {ComponentClass, FunctionComponent} from 'react'
 2 | 
 3 | export interface DashboardWidget {
 4 |   name: string
 5 |   type?: '__experimental_group'
 6 |   component: FunctionComponent | ComponentClass
 7 |   layout?: LayoutConfig
 8 |   widgets?: DashboardWidget[]
 9 | }
10 | 
11 | export type LayoutSize = 'auto' | 'small' | 'medium' | 'large' | 'full'
12 | 
13 | export interface LayoutConfig {
14 |   width?: LayoutSize
15 |   height?: LayoutSize
16 | }
17 | 
18 | export interface DashboardConfig {
19 |   widgets: DashboardWidget[]
20 |   layout?: LayoutConfig
21 | }
22 | 
--------------------------------------------------------------------------------
/src/versionedClient.ts:
--------------------------------------------------------------------------------
1 | import {useClient} from 'sanity'
2 | 
3 | export function useVersionedClient() {
4 |   return useClient({apiVersion: '2024-08-01'})
5 | }
6 | 
--------------------------------------------------------------------------------
/src/widgets/projectInfo/ProjectInfo.tsx:
--------------------------------------------------------------------------------
  1 | import React, {useEffect, useMemo, useState} from 'react'
  2 | import {Box, Card, Stack, Heading, Grid, Label, Text, Code, Button} from '@sanity/ui'
  3 | import {useVersionedClient} from '../../versionedClient'
  4 | import {Subscription} from 'rxjs'
  5 | import {WidgetContainer} from '../../containers/WidgetContainer'
  6 | import {DashboardWidgetContainer} from '../../components/DashboardWidgetContainer'
  7 | import {type DashboardWidget} from '../../types'
  8 | import {type App, type ProjectInfoProps, type ProjectData, UserApplication} from './types'
  9 | 
 10 | function isUrl(url?: string) {
 11 |   return url && /^https?:\/\//.test(`${url}`)
 12 | }
 13 | 
 14 | function getGraphQLUrl(projectId: string, dataset: string) {
 15 |   return `https://${projectId}.api.sanity.io/v1/graphql/${dataset}/default`
 16 | }
 17 | 
 18 | function getGroqUrl(projectId: string, dataset: string) {
 19 |   return `https://${projectId}.api.sanity.io/v1/groq/${dataset}`
 20 | }
 21 | 
 22 | function getManageUrl(projectId: string) {
 23 |   return `https://manage.sanity.io/projects/${projectId}`
 24 | }
 25 | 
 26 | const NO_EXPERIMENTAL: DashboardWidget[] = []
 27 | const NO_DATA: ProjectData[] = []
 28 | 
 29 | export function ProjectInfo(props: ProjectInfoProps) {
 30 |   const {__experimental_before = NO_EXPERIMENTAL, data = NO_DATA} = props
 31 |   const [studioApps, setStudioApps] = useState()
 32 |   const [graphQLApi, setGraphQLApi] = useState()
 33 |   const versionedClient = useVersionedClient()
 34 |   const {projectId = 'unknown', dataset = 'unknown'} = versionedClient.config()
 35 | 
 36 |   useEffect(() => {
 37 |     const subscriptions: Subscription[] = []
 38 | 
 39 |     subscriptions.push(
 40 |       versionedClient.observable
 41 |         .request({uri: '/user-applications', tag: 'dashboard.project-info'})
 42 |         .subscribe({
 43 |           next: (result) => setStudioApps(result.filter((app) => app.type === 'studio')),
 44 |           error: (error) => {
 45 |             console.error('Error while resolving user applications', error)
 46 |             setStudioApps({
 47 |               error: 'Something went wrong while resolving user applications. See console.',
 48 |             })
 49 |           },
 50 |         }),
 51 |     )
 52 | 
 53 |     // ping assumed graphql endpoint
 54 |     subscriptions.push(
 55 |       versionedClient.observable
 56 |         .request({
 57 |           method: 'HEAD',
 58 |           uri: `/graphql/${dataset}/default`,
 59 |           tag: 'dashboard.project-info.graphql-api',
 60 |         })
 61 |         .subscribe({
 62 |           next: () => setGraphQLApi(getGraphQLUrl(projectId, dataset)),
 63 |           error: (error) => {
 64 |             if (error.statusCode === 404) {
 65 |               setGraphQLApi(undefined)
 66 |             } else {
 67 |               console.error('Error while looking for graphQLApi', error)
 68 |               setGraphQLApi({
 69 |                 error: 'Something went wrong while looking up graphQLApi. See console.',
 70 |               })
 71 |             }
 72 |           },
 73 |         }),
 74 |     )
 75 | 
 76 |     return () => {
 77 |       subscriptions.forEach((s) => s.unsubscribe())
 78 |     }
 79 |   }, [dataset, projectId, versionedClient, setGraphQLApi])
 80 | 
 81 |   const assembleTableRows = useMemo(() => {
 82 |     let result: App[] = [
 83 |       {
 84 |         title: 'Sanity project',
 85 |         rows: [
 86 |           {title: 'Project ID', value: projectId},
 87 |           {title: 'Dataset', value: dataset},
 88 |         ],
 89 |       },
 90 |     ]
 91 | 
 92 |     const apps: App[] = data.filter((item) => item.category === 'apps')
 93 | 
 94 |     // Handle studios
 95 |     ;(Array.isArray(studioApps) ? studioApps : []).forEach((app) => {
 96 |       apps.push({
 97 |         title: app.title || 'Studio',
 98 |         value: app.urlType === 'internal' ? `https://${app.appHost}.sanity.studio` : app.appHost,
 99 |       })
100 |     })
101 | 
102 |     if (apps.length > 0) {
103 |       result = result.concat([{title: 'Apps', rows: apps}])
104 |     }
105 | 
106 |     // Handle APIs
107 |     result = result.concat(
108 |       [
109 |         {
110 |           title: 'APIs',
111 |           rows: [
112 |             {title: 'GROQ', value: getGroqUrl(projectId, dataset)},
113 |             {
114 |               title: 'GraphQL',
115 |               value: (typeof graphQLApi === 'object' ? 'Error' : graphQLApi) ?? 'Not deployed',
116 |             },
117 |           ],
118 |         },
119 |       ],
120 |       data.filter((item) => item.category === 'apis'),
121 |     )
122 | 
123 |     // Handle whatever else there might be
124 |     const otherStuff: Record = {}
125 |     data.forEach((item) => {
126 |       if (item.category && item.category !== 'apps' && item.category !== 'apis') {
127 |         if (!otherStuff[item.category]) {
128 |           otherStuff[item.category] = []
129 |         }
130 |         otherStuff[item.category].push(item)
131 |       }
132 |     })
133 |     Object.keys(otherStuff).forEach((category) => {
134 |       result.push({title: category, rows: otherStuff[category]})
135 |     })
136 | 
137 |     return result
138 |   }, [graphQLApi, studioApps, projectId, dataset, data])
139 | 
140 |   return (
141 |     <>
142 |       {__experimental_before.map((widgetConfig, idx) => (
143 |         
144 |       ))}
145 |        0 ? 4 : 0}>
146 |         
158 |           }
159 |         >
160 |           
167 |             
168 |               
169 |                 
170 |                   Project info
171 |                 
172 |               
173 |               {assembleTableRows.map((item) => {
174 |                 if (!item || !item.rows) {
175 |                   return null
176 |                 }
177 | 
178 |                 return (
179 |                   
180 |                     
181 |                       
184 |                     
185 |                     
186 |                       {item.rows.map((row) => {
187 |                         return (
188 |                           
189 |                             
190 |                               {row.title}
191 |                             
192 |                             {typeof row.value === 'object' && (
193 |                               {row.value?.error}
194 |                             )}
195 |                             {typeof row.value === 'string' && (
196 |                               <>
197 |                                 {isUrl(row.value) ? (
198 |                                   
199 |                                     {row.value}
200 |                                   
201 |                                 ) : (
202 |
148 |   
149 | 
150 | ```
151 | 
152 | ### More examples
153 | 
154 | You can study the source code of these widgets to get a sense of how you can approach fetching of documents, adding configuration, and so on:
155 | 
156 | - [dashboard-widget-document-list](https://github.com/sanity-io/dashboard-widget-document-list)
157 | - [dashboard-widget-widget-document-count](https://github.com/sanity-io/example-dashboard-widget-document-count)
158 | - [dashboard-widget-netlify](https://github.com/sanity-io/sanity-plugin-dashboard-widget-netlify)
159 | 
160 | ---
161 | 
162 | ### Upgrading from v2
163 | 
164 | If you were previously using @sanity/dashboard in a v2 Sanity Studio, will have to make the following changes:
165 | 
166 | - Install the v3 version of @sanity/dashboard in the Studio
167 | - Install v3 versions of any widgets
168 | - Configure the dashboard as described above:
169 |   - Add dashboardTool to plugins array
170 |   - Add widgets to widgets configuration
171 |   - Move any config you had in v2 `dashboardConfiguration.js` on a widget-by-widget basis.
172 |   - V2 used an options-object to pass widget-specific configuration. In v3, options have been replaced by
173 |     passing the same configuration directly to the widget-function.
174 | - Custom widget components should import DashboardWidgetContainer instead of DashboardWidget
175 | 
176 | ## Develop & test
177 | 
178 | This plugin uses [@sanity/plugin-kit](https://github.com/sanity-io/plugin-kit)
179 | with default configuration for build & watch scripts.
180 | 
181 | See [Testing a plugin in Sanity Studio](https://github.com/sanity-io/plugin-kit#testing-a-plugin-in-sanity-studio)
182 | on how to run this plugin with hotreload in the studio.
183 | 
184 | ### Release new version
185 | 
186 | Run ["CI & Release" workflow](https://github.com/sanity-io/dashboard/actions/workflows/main.yml).
187 | Make sure to select the main branch and check "Release new version".
188 | 
189 | Semantic release will only release on configured branches, so it is safe to run release on any branch.
190 | 
191 | ## License
192 | 
193 | MIT-licensed. See LICENSE.
194 | 
195 | ## Develop & test
196 | 
197 | This plugin uses [@sanity/plugin-kit](https://github.com/sanity-io/plugin-kit)
198 | with default configuration for build & watch scripts.
199 | 
200 | See [Testing a plugin in Sanity Studio](https://github.com/sanity-io/plugin-kit#testing-a-plugin-in-sanity-studio)
201 | on how to run this plugin with hotreload in the studio.
202 | 
203 | ### Release new version
204 | 
205 | Run ["CI & Release" workflow](https://github.com/sanity-io/dashboard/actions/workflows/main.yml).
206 | Make sure to select the main branch and check "Release new version".
207 | 
208 | Semantic release will only release on configured branches, so it is safe to run release on any branch.
209 | 
--------------------------------------------------------------------------------
/assets/dashboard.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sanity-io/dashboard/fed22f592ea43913d359acdb4a3bacc0452c4873/assets/dashboard.png
--------------------------------------------------------------------------------
/commitlint.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 |   extends: ['@commitlint/config-conventional'],
3 | }
4 | 
--------------------------------------------------------------------------------
/lint-staged.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 |   '**/*.{js,jsx}': ['eslint'],
3 |   '**/*.{ts,tsx}': ['eslint', () => 'tsc --noEmit'],
4 | }
5 | 
--------------------------------------------------------------------------------
/package.config.ts:
--------------------------------------------------------------------------------
 1 | import {defineConfig} from '@sanity/pkg-utils'
 2 | 
 3 | export default defineConfig({
 4 |   legacyExports: true,
 5 |   dist: 'lib',
 6 |   tsconfig: 'tsconfig.lib.json',
 7 | 
 8 |   // Remove this block to enable strict export validation
 9 |   extract: {
10 |     rules: {
11 |       'ae-forgotten-export': 'off',
12 |       'ae-incompatible-release-tags': 'off',
13 |       'ae-internal-missing-underscore': 'off',
14 |       'ae-missing-release-tag': 'off',
15 |     },
16 |   },
17 | })
18 | 
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
  1 | {
  2 |   "name": "@sanity/dashboard",
  3 |   "version": "5.0.0",
  4 |   "description": "Tool for rendering dashboard widgets",
  5 |   "keywords": [
  6 |     "sanity",
  7 |     "cms",
  8 |     "headless",
  9 |     "realtime",
 10 |     "content",
 11 |     "dashboard",
 12 |     "sanity-plugin",
 13 |     "sanity-tool"
 14 |   ],
 15 |   "homepage": "https://github.com/sanity-io/dashboard#readme",
 16 |   "bugs": {
 17 |     "url": "https://github.com/sanity-io/dashboard/issues"
 18 |   },
 19 |   "repository": {
 20 |     "type": "git",
 21 |     "url": "git@github.com:sanity-io/dashboard.git"
 22 |   },
 23 |   "license": "MIT",
 24 |   "author": "Sanity.io ",
 25 |   "sideEffects": false,
 26 |   "exports": {
 27 |     ".": {
 28 |       "source": "./src/index.ts",
 29 |       "import": "./lib/index.mjs",
 30 |       "require": "./lib/index.js",
 31 |       "default": "./lib/index.mjs"
 32 |     },
 33 |     "./package.json": "./package.json"
 34 |   },
 35 |   "main": "./lib/index.js",
 36 |   "module": "./lib/index.esm.js",
 37 |   "types": "./lib/index.d.ts",
 38 |   "files": [
 39 |     "lib",
 40 |     "sanity.json",
 41 |     "src",
 42 |     "v2-incompatible.js"
 43 |   ],
 44 |   "scripts": {
 45 |     "prebuild": "npm run clean && plugin-kit verify-package --silent && pkg-utils",
 46 |     "build": "plugin-kit verify-package --silent && pkg-utils build --strict --check --clean",
 47 |     "clean": "rimraf lib",
 48 |     "compile": "tsc --noEmit",
 49 |     "dev": "sanity dev",
 50 |     "format": "prettier --write --cache --ignore-unknown .",
 51 |     "link-watch": "plugin-kit link-watch",
 52 |     "lint": "eslint .",
 53 |     "prepare": "husky install",
 54 |     "prepublishOnly": "npm run build",
 55 |     "watch": "pkg-utils watch --strict"
 56 |   },
 57 |   "browserslist": "extends @sanity/browserslist-config",
 58 |   "dependencies": {
 59 |     "@sanity/icons": "^3.5.2",
 60 |     "@sanity/image-url": "^1.1.0",
 61 |     "@sanity/incompatible-plugin": "^1.0.4",
 62 |     "@sanity/ui": "^3.1.0",
 63 |     "lodash": "^4.17.21",
 64 |     "rxjs": "^7.8.1"
 65 |   },
 66 |   "devDependencies": {
 67 |     "@commitlint/cli": "^19.8.0",
 68 |     "@commitlint/config-conventional": "^19.8.0",
 69 |     "@sanity/pkg-utils": "^6.13.4",
 70 |     "@sanity/plugin-kit": "^4.0.19",
 71 |     "@sanity/semantic-release-preset": "^5.0.0",
 72 |     "@types/react": "^18.3.18",
 73 |     "@typescript-eslint/eslint-plugin": "^7.18.0",
 74 |     "@typescript-eslint/parser": "^7.18.0",
 75 |     "eslint": "^8.57.1",
 76 |     "eslint-config-prettier": "^9.1.0",
 77 |     "eslint-config-sanity": "^6.0.0",
 78 |     "eslint-plugin-prettier": "^5.2.3",
 79 |     "eslint-plugin-react": "^7.37.4",
 80 |     "eslint-plugin-react-hooks": "^4.6.2",
 81 |     "husky": "^8.0.3",
 82 |     "lint-staged": "^15.2.11",
 83 |     "npm-run-all2": "^5.0.2",
 84 |     "prettier": "^3.5.3",
 85 |     "prettier-plugin-packagejson": "^2.5.10",
 86 |     "react": "^18.3.1",
 87 |     "react-dom": "^18.3.1",
 88 |     "react-is": "^18.3.1",
 89 |     "rimraf": "^6.0.0",
 90 |     "sanity": "^3.78.1",
 91 |     "semantic-release": "^24.2.3",
 92 |     "styled-components": "^6.1.15",
 93 |     "typescript": "^5.7.3"
 94 |   },
 95 |   "peerDependencies": {
 96 |     "react": "^18 || >=19.0.0-0",
 97 |     "sanity": "^3 || ^4.0.0-0",
 98 |     "styled-components": "^6.1"
 99 |   },
100 |   "engines": {
101 |     "node": ">=18"
102 |   },
103 |   "overrides": {
104 |     "conventional-changelog-conventionalcommits": ">= 8.0.0",
105 |     "cross-spawn": "^7.0.6"
106 |   },
107 |   "sanityExchangeUrl": "https://www.sanity.io/exchange/dashboard"
108 | }
109 | 
--------------------------------------------------------------------------------
/renovate.json:
--------------------------------------------------------------------------------
1 | {
2 |   "$schema": "https://docs.renovatebot.com/renovate-schema.json",
3 |   "extends": [
4 |     "github>sanity-io/renovate-presets//ecosystem/auto",
5 |     "github>sanity-io/renovate-presets//ecosystem/studio-v3"
6 |   ]
7 | }
8 | 
--------------------------------------------------------------------------------
/sanity.config.ts:
--------------------------------------------------------------------------------
 1 | import {defineConfig} from 'sanity'
 2 | import {
 3 |   dashboardTool,
 4 |   projectInfoWidget,
 5 |   projectUsersWidget,
 6 |   sanityTutorialsWidget,
 7 | } from './src/index'
 8 | 
 9 | export default defineConfig({
10 |   projectId: 'ppsg7ml5',
11 |   dataset: 'test',
12 |   plugins: [
13 |     dashboardTool({
14 |       widgets: [sanityTutorialsWidget(), projectUsersWidget(), projectInfoWidget()],
15 |     }),
16 |   ],
17 |   scheduledPublishing: {
18 |     enabled: false,
19 |   },
20 |   tasks: {
21 |     enabled: false,
22 |   },
23 | })
24 | 
--------------------------------------------------------------------------------
/sanity.json:
--------------------------------------------------------------------------------
1 | {
2 |   "parts": [
3 |     {
4 |       "implements": "part:@sanity/base/sanity-root",
5 |       "path": "./v2-incompatible.js"
6 |     }
7 |   ]
8 | }
9 | 
--------------------------------------------------------------------------------
/src/components/DashboardLayout.tsx:
--------------------------------------------------------------------------------
 1 | import React, {PropsWithChildren} from 'react'
 2 | import {Container} from '@sanity/ui'
 3 | 
 4 | export function DashboardLayout(props: PropsWithChildren<{}>) {
 5 |   return (
 6 |     
 7 |       {props.children}
 8 |     
 9 |   )
10 | }
11 | 
--------------------------------------------------------------------------------
/src/components/DashboardWidgetContainer.tsx:
--------------------------------------------------------------------------------
 1 | import React, {forwardRef} from 'react'
 2 | import {Card, Box, Heading} from '@sanity/ui'
 3 | import {styled} from 'styled-components'
 4 | 
 5 | const Root = styled(Card)`
 6 |   display: flex;
 7 |   flex-direction: column;
 8 |   justify-content: stretch;
 9 |   height: 100%;
10 |   box-sizing: border-box;
11 |   position: relative;
12 | `
13 | 
14 | const Header = styled(Card)`
15 |   position: sticky;
16 |   top: 0;
17 |   z-index: 2;
18 |   border-top-left-radius: inherit;
19 |   border-top-right-radius: inherit;
20 | `
21 | 
22 | const Footer = styled(Card)`
23 |   position: sticky;
24 |   overflow: hidden;
25 |   bottom: 0;
26 |   z-index: 2;
27 |   border-bottom-right-radius: inherit;
28 |   border-bottom-left-radius: inherit;
29 |   margin-top: auto;
30 | `
31 | 
32 | const Content = styled(Box)`
33 |   position: relative;
34 |   z-index: 1;
35 |   height: stretch;
36 |   min-height: 21.5em;
37 | 
38 |   @media (min-width: ${({theme}) => theme.sanity.media[0]}px) {
39 |     overflow-y: auto;
40 |     outline: none;
41 |   }
42 | `
43 | 
44 | interface DashboardWidgetProps {
45 |   header?: string
46 |   children: React.ReactNode
47 |   footer?: React.ReactNode
48 | }
49 | 
50 | export const DashboardWidgetContainer = forwardRef(function DashboardWidgetContainer(
51 |   props: DashboardWidgetProps,
52 |   ref: React.Ref,
53 | ) {
54 |   const {header, children, footer} = props
55 | 
56 |   return (
57 |     
58 |       {header && (
59 |         
60 |           
61 |             {header}
62 |           
63 |         
64 |       )}
65 |       {children && {children}}
66 |       {footer && }
67 |     
68 |   )
69 | })
70 | 
--------------------------------------------------------------------------------
/src/components/NotFoundWidget.tsx:
--------------------------------------------------------------------------------
 1 | import React, {PropsWithChildren, ReactNode} from 'react'
 2 | import {Card, Stack, Heading, Box} from '@sanity/ui'
 3 | import {styled} from 'styled-components'
 4 | 
 5 | const Root = styled(Card)`
 6 |   display: flex;
 7 |   flex-direction: column;
 8 |   justify-content: stretch;
 9 |   height: 100%;
10 | `
11 | 
12 | export type NotFoundWidgetProps = PropsWithChildren<{
13 |   title?: ReactNode
14 | }>
15 | 
16 | export function NotFoundWidget(props: NotFoundWidgetProps) {
17 |   const {title, children} = props
18 |   return (
19 |     
20 |       
21 |         {title && (
22 |           
23 |             {title}
24 |           
25 |         )}
26 |         {children && {children}}
27 |       
28 |     
29 |   )
30 | }
31 | 
--------------------------------------------------------------------------------
/src/components/WidgetGroup.tsx:
--------------------------------------------------------------------------------
  1 | import React from 'react'
  2 | import {styled, css} from 'styled-components'
  3 | import {Box, Card, Grid, Text} from '@sanity/ui'
  4 | import {WidgetContainer} from '../containers/WidgetContainer'
  5 | import {DashboardConfig, LayoutConfig, DashboardWidget} from '../types'
  6 | 
  7 | const media = {
  8 |   small: (...args: Parameters) => css`
  9 |     @media (min-width: ${({theme}) => theme.sanity.media[0]}px) {
 10 |       ${css(...args)}
 11 |     }
 12 |   `,
 13 |   medium: (...args: Parameters) => css`
 14 |     @media (min-width: ${({theme}) => theme.sanity.media[2]}px) {
 15 |       ${css(...args)}
 16 |     }
 17 |   `,
 18 | }
 19 | 
 20 | const Root = styled(Grid)`
 21 |   grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
 22 | 
 23 |   & > div {
 24 |     overflow: hidden;
 25 |   }
 26 | 
 27 |   & > div[data-width='medium'] {
 28 |     ${media.small`
 29 |       grid-column: span 2;
 30 |     `}
 31 |   }
 32 | 
 33 |   & > div[data-width='large'] {
 34 |     ${media.small`
 35 |       grid-column: span 2;
 36 |     `}
 37 | 
 38 |     ${media.medium`
 39 |       grid-column: span 3;
 40 |     `}
 41 |   }
 42 | 
 43 |   & > div[data-width='full'] {
 44 |     ${media.small`
 45 |       grid-column: 1 / -1;
 46 |     `}
 47 |   }
 48 | 
 49 |   & > div[data-height='medium'] {
 50 |     ${media.small`
 51 |       grid-row: span 2;
 52 |     `}
 53 |   }
 54 | 
 55 |   & > div[data-height='large'] {
 56 |     ${media.small`
 57 |       grid-row: span 2;
 58 |     `}
 59 | 
 60 |     ${media.medium`
 61 |       grid-row: span 3;
 62 |     `}
 63 |   }
 64 | 
 65 |   & > div[data-height='full'] {
 66 |     ${media.medium`
 67 |       grid-row: 1 / -1;
 68 |     `}
 69 |   }
 70 | `
 71 | 
 72 | export interface WidgetGroupProps {
 73 |   config: Partial
 74 | }
 75 | 
 76 | const NO_WIDGETS: DashboardWidget[] = []
 77 | const NO_LAYOUT: LayoutConfig = {}
 78 | 
 79 | export function WidgetGroup(props: WidgetGroupProps) {
 80 |   const {
 81 |     config: {layout = NO_LAYOUT, widgets = NO_WIDGETS},
 82 |   } = props
 83 |   return (
 84 |     
 90 |       {widgets.length ? null : (
 91 |         
 92 |           Add some widgets to populate this space.
 93 |         
 94 |       )}
 95 |       {widgets.map((widgetConfig, index) => {
 96 |         if (widgetConfig.type === '__experimental_group') {
 97 |           return 
 98 |         }
 99 |         if (widgetConfig.component) {
100 |           return 
101 |         }
102 |         return {widgetConfig.name} is missing widget component
103 |       })}
104 |     
105 |   )
106 | }
107 | 
--------------------------------------------------------------------------------
/src/containers/Dashboard.tsx:
--------------------------------------------------------------------------------
 1 | import React from 'react'
 2 | import {DashboardLayout} from '../components/DashboardLayout'
 3 | import {WidgetGroup} from '../components/WidgetGroup'
 4 | import {DashboardContext} from './DashboardContext'
 5 | import {DashboardConfig} from '../types'
 6 | 
 7 | export function Dashboard({config}: {config: DashboardConfig}) {
 8 |   if (!config) {
 9 |     return null
10 |   }
11 | 
12 |   return (
13 |     
14 |       
15 |         
16 |       
17 |     
18 |   )
19 | }
20 | 
--------------------------------------------------------------------------------
/src/containers/DashboardContext.tsx:
--------------------------------------------------------------------------------
1 | import {createContext, useContext} from 'react'
2 | import {DashboardConfig} from '../types'
3 | 
4 | export const DashboardContext = createContext({widgets: []})
5 | 
6 | export function useDashboardConfig(): DashboardConfig {
7 |   return useContext(DashboardContext)
8 | }
9 | 
--------------------------------------------------------------------------------
/src/containers/WidgetContainer.tsx:
--------------------------------------------------------------------------------
 1 | import React, {createElement, useMemo} from 'react'
 2 | import {useDashboardConfig} from './DashboardContext'
 3 | import {Card} from '@sanity/ui'
 4 | import {DashboardWidget} from '../types'
 5 | 
 6 | export function WidgetContainer(props: DashboardWidget) {
 7 |   const config = useDashboardConfig()
 8 |   const layout = useMemo(
 9 |     () => ({
10 |       ...(props.layout || {}),
11 |       ...(config.layout || {}),
12 |     }),
13 |     [props.layout, config.layout],
14 |   )
15 | 
16 |   return (
17 |     
18 |       {createElement(props.component, {})}
19 |     
20 |   )
21 | }
22 | 
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | export * from './types'
2 | export * from './components/DashboardWidgetContainer'
3 | export * from './widgets/projectInfo'
4 | export * from './widgets/projectUsers'
5 | export * from './widgets/sanityTutorials'
6 | 
7 | export {type DashboardPluginConfig, dashboardTool} from './plugin'
8 | 
--------------------------------------------------------------------------------
/src/plugin.tsx:
--------------------------------------------------------------------------------
 1 | import React, {ComponentType, CSSProperties} from 'react'
 2 | import {Dashboard} from './containers/Dashboard'
 3 | import {definePlugin} from 'sanity'
 4 | import {DashboardConfig, DashboardWidget, LayoutConfig} from './types'
 5 | 
 6 | const strokeStyle: CSSProperties = {
 7 |   stroke: 'currentColor',
 8 |   strokeWidth: 1.2,
 9 | }
10 | 
11 | const DashboardIcon = () => (
12 |   
25 | )
26 | 
27 | export interface DashboardPluginConfig {
28 |   /**
29 |    * Dashboard tool title
30 |    */
31 |   title?: string
32 |   /**
33 |    * Dashboard tool name (used in url path)
34 |    */
35 |   name?: string
36 |   /**
37 |    * Dashboard tool icon
38 |    */
39 |   icon?: ComponentType
40 |   widgets?: DashboardWidget[]
41 | 
42 |   /**
43 |    * Will be used for widgets that do not define a layout directly.
44 |    */
45 |   defaultLayout?: LayoutConfig
46 | }
47 | 
48 | export const dashboardTool = definePlugin((config = {}) => {
49 |   const pluginConfig: DashboardConfig = {
50 |     layout: config.defaultLayout ?? {},
51 |     widgets: config.widgets ?? [],
52 |   }
53 | 
54 |   const title = config.title ?? 'Dashboard'
55 |   const name = config.name ?? 'dashboard'
56 |   const icon = config.icon ?? DashboardIcon
57 | 
58 |   return {
59 |     name: 'dashboard',
60 |     tools: (prev, context) => {
61 |       return [
62 |         ...prev,
63 |         {
64 |           title,
65 |           name,
66 |           icon,
67 |           component: () => ,
68 |         },
69 |       ]
70 |     },
71 |   }
72 | })
73 | 
--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------
 1 | import {ComponentClass, FunctionComponent} from 'react'
 2 | 
 3 | export interface DashboardWidget {
 4 |   name: string
 5 |   type?: '__experimental_group'
 6 |   component: FunctionComponent | ComponentClass
 7 |   layout?: LayoutConfig
 8 |   widgets?: DashboardWidget[]
 9 | }
10 | 
11 | export type LayoutSize = 'auto' | 'small' | 'medium' | 'large' | 'full'
12 | 
13 | export interface LayoutConfig {
14 |   width?: LayoutSize
15 |   height?: LayoutSize
16 | }
17 | 
18 | export interface DashboardConfig {
19 |   widgets: DashboardWidget[]
20 |   layout?: LayoutConfig
21 | }
22 | 
--------------------------------------------------------------------------------
/src/versionedClient.ts:
--------------------------------------------------------------------------------
1 | import {useClient} from 'sanity'
2 | 
3 | export function useVersionedClient() {
4 |   return useClient({apiVersion: '2024-08-01'})
5 | }
6 | 
--------------------------------------------------------------------------------
/src/widgets/projectInfo/ProjectInfo.tsx:
--------------------------------------------------------------------------------
  1 | import React, {useEffect, useMemo, useState} from 'react'
  2 | import {Box, Card, Stack, Heading, Grid, Label, Text, Code, Button} from '@sanity/ui'
  3 | import {useVersionedClient} from '../../versionedClient'
  4 | import {Subscription} from 'rxjs'
  5 | import {WidgetContainer} from '../../containers/WidgetContainer'
  6 | import {DashboardWidgetContainer} from '../../components/DashboardWidgetContainer'
  7 | import {type DashboardWidget} from '../../types'
  8 | import {type App, type ProjectInfoProps, type ProjectData, UserApplication} from './types'
  9 | 
 10 | function isUrl(url?: string) {
 11 |   return url && /^https?:\/\//.test(`${url}`)
 12 | }
 13 | 
 14 | function getGraphQLUrl(projectId: string, dataset: string) {
 15 |   return `https://${projectId}.api.sanity.io/v1/graphql/${dataset}/default`
 16 | }
 17 | 
 18 | function getGroqUrl(projectId: string, dataset: string) {
 19 |   return `https://${projectId}.api.sanity.io/v1/groq/${dataset}`
 20 | }
 21 | 
 22 | function getManageUrl(projectId: string) {
 23 |   return `https://manage.sanity.io/projects/${projectId}`
 24 | }
 25 | 
 26 | const NO_EXPERIMENTAL: DashboardWidget[] = []
 27 | const NO_DATA: ProjectData[] = []
 28 | 
 29 | export function ProjectInfo(props: ProjectInfoProps) {
 30 |   const {__experimental_before = NO_EXPERIMENTAL, data = NO_DATA} = props
 31 |   const [studioApps, setStudioApps] = useState()
 32 |   const [graphQLApi, setGraphQLApi] = useState()
 33 |   const versionedClient = useVersionedClient()
 34 |   const {projectId = 'unknown', dataset = 'unknown'} = versionedClient.config()
 35 | 
 36 |   useEffect(() => {
 37 |     const subscriptions: Subscription[] = []
 38 | 
 39 |     subscriptions.push(
 40 |       versionedClient.observable
 41 |         .request({uri: '/user-applications', tag: 'dashboard.project-info'})
 42 |         .subscribe({
 43 |           next: (result) => setStudioApps(result.filter((app) => app.type === 'studio')),
 44 |           error: (error) => {
 45 |             console.error('Error while resolving user applications', error)
 46 |             setStudioApps({
 47 |               error: 'Something went wrong while resolving user applications. See console.',
 48 |             })
 49 |           },
 50 |         }),
 51 |     )
 52 | 
 53 |     // ping assumed graphql endpoint
 54 |     subscriptions.push(
 55 |       versionedClient.observable
 56 |         .request({
 57 |           method: 'HEAD',
 58 |           uri: `/graphql/${dataset}/default`,
 59 |           tag: 'dashboard.project-info.graphql-api',
 60 |         })
 61 |         .subscribe({
 62 |           next: () => setGraphQLApi(getGraphQLUrl(projectId, dataset)),
 63 |           error: (error) => {
 64 |             if (error.statusCode === 404) {
 65 |               setGraphQLApi(undefined)
 66 |             } else {
 67 |               console.error('Error while looking for graphQLApi', error)
 68 |               setGraphQLApi({
 69 |                 error: 'Something went wrong while looking up graphQLApi. See console.',
 70 |               })
 71 |             }
 72 |           },
 73 |         }),
 74 |     )
 75 | 
 76 |     return () => {
 77 |       subscriptions.forEach((s) => s.unsubscribe())
 78 |     }
 79 |   }, [dataset, projectId, versionedClient, setGraphQLApi])
 80 | 
 81 |   const assembleTableRows = useMemo(() => {
 82 |     let result: App[] = [
 83 |       {
 84 |         title: 'Sanity project',
 85 |         rows: [
 86 |           {title: 'Project ID', value: projectId},
 87 |           {title: 'Dataset', value: dataset},
 88 |         ],
 89 |       },
 90 |     ]
 91 | 
 92 |     const apps: App[] = data.filter((item) => item.category === 'apps')
 93 | 
 94 |     // Handle studios
 95 |     ;(Array.isArray(studioApps) ? studioApps : []).forEach((app) => {
 96 |       apps.push({
 97 |         title: app.title || 'Studio',
 98 |         value: app.urlType === 'internal' ? `https://${app.appHost}.sanity.studio` : app.appHost,
 99 |       })
100 |     })
101 | 
102 |     if (apps.length > 0) {
103 |       result = result.concat([{title: 'Apps', rows: apps}])
104 |     }
105 | 
106 |     // Handle APIs
107 |     result = result.concat(
108 |       [
109 |         {
110 |           title: 'APIs',
111 |           rows: [
112 |             {title: 'GROQ', value: getGroqUrl(projectId, dataset)},
113 |             {
114 |               title: 'GraphQL',
115 |               value: (typeof graphQLApi === 'object' ? 'Error' : graphQLApi) ?? 'Not deployed',
116 |             },
117 |           ],
118 |         },
119 |       ],
120 |       data.filter((item) => item.category === 'apis'),
121 |     )
122 | 
123 |     // Handle whatever else there might be
124 |     const otherStuff: Record = {}
125 |     data.forEach((item) => {
126 |       if (item.category && item.category !== 'apps' && item.category !== 'apis') {
127 |         if (!otherStuff[item.category]) {
128 |           otherStuff[item.category] = []
129 |         }
130 |         otherStuff[item.category].push(item)
131 |       }
132 |     })
133 |     Object.keys(otherStuff).forEach((category) => {
134 |       result.push({title: category, rows: otherStuff[category]})
135 |     })
136 | 
137 |     return result
138 |   }, [graphQLApi, studioApps, projectId, dataset, data])
139 | 
140 |   return (
141 |     <>
142 |       {__experimental_before.map((widgetConfig, idx) => (
143 |         
144 |       ))}
145 |        0 ? 4 : 0}>
146 |         
158 |           }
159 |         >
160 |           
167 |             
168 |               
169 |                 
170 |                   Project info
171 |                 
172 |               
173 |               {assembleTableRows.map((item) => {
174 |                 if (!item || !item.rows) {
175 |                   return null
176 |                 }
177 | 
178 |                 return (
179 |                   
180 |                     
181 |                       
184 |                     
185 |                     
186 |                       {item.rows.map((row) => {
187 |                         return (
188 |                           
189 |                             
190 |                               {row.title}
191 |                             
192 |                             {typeof row.value === 'object' && (
193 |                               {row.value?.error}
194 |                             )}
195 |                             {typeof row.value === 'string' && (
196 |                               <>
197 |                                 {isUrl(row.value) ? (
198 |                                   
199 |                                     {row.value}
200 |                                   
201 |                                 ) : (
202 |                                   
203 |                                     {row.value}
204 |                                   
205 |                                 )}
206 |                               >
207 |                             )}
208 |                           
209 |                         )
210 |                       })}
211 |                     
212 |                   
213 |                 )
214 |               })}
215 |             
216 |           
217 |         
218 |       
219 |     >
220 |   )
221 | }
222 | 
--------------------------------------------------------------------------------
/src/widgets/projectInfo/index.ts:
--------------------------------------------------------------------------------
 1 | import {ProjectInfo} from './ProjectInfo'
 2 | import {type LayoutConfig, type DashboardWidget} from '../../types'
 3 | 
 4 | export function projectInfoWidget(config?: {layout?: LayoutConfig}): DashboardWidget {
 5 |   return {
 6 |     name: 'project-info',
 7 |     component: ProjectInfo,
 8 |     layout: config?.layout ?? {width: 'medium'},
 9 |   }
10 | }
11 | 
--------------------------------------------------------------------------------
/src/widgets/projectInfo/types.ts:
--------------------------------------------------------------------------------
 1 | import {type DashboardWidget} from '../../types'
 2 | 
 3 | export interface ProjectInfoProps {
 4 |   __experimental_before?: DashboardWidget[]
 5 |   data: ProjectData[]
 6 | }
 7 | 
 8 | export interface App {
 9 |   title: string
10 |   rows?: App[]
11 |   value?: string | {error: string}
12 | }
13 | 
14 | export interface ProjectData {
15 |   title: string
16 |   category?: string
17 | }
18 | 
19 | export interface UserApplication {
20 |   id: string
21 |   projectId: string
22 |   title: string | null
23 |   type: string
24 |   urlType: 'internal' | 'external'
25 |   appHost: string
26 | 
27 |   // … there are other props here, but we don't really care about them for our use case
28 | }
29 | 
--------------------------------------------------------------------------------
/src/widgets/projectUsers/ProjectUser.tsx:
--------------------------------------------------------------------------------
 1 | import React from 'react'
 2 | import {Box, Flex, rem, Stack, Text} from '@sanity/ui'
 3 | import {styled} from 'styled-components'
 4 | import {useListFormat, type User, UserAvatar} from 'sanity'
 5 | import {RobotIcon} from '@sanity/icons'
 6 | 
 7 | const Root = styled(Flex)`
 8 |   height: ${rem(33)}; // 33 = PREVIEW_SIZES.default.media.height
 9 |   box-sizing: content-box;
10 | `
11 | 
12 | export interface ProjectUserProps {
13 |   user: User
14 |   isRobot: boolean
15 |   roles: string[]
16 | }
17 | 
18 | export function ProjectUser({user, isRobot, roles}: ProjectUserProps) {
19 |   const listFormat = useListFormat({style: 'narrow'})
20 |   return (
21 |     
22 |       
23 |         
24 |           {isRobot ? (
25 |             
26 |               
27 |             
28 |           ) : (
29 |             
30 |           )}
31 |         
32 | 
33 |         
34 |           
35 |             {user.displayName}
36 |           
37 | 
38 |           
39 |             {listFormat.format(roles)}
40 |           
41 |         
42 |       
43 |     
44 |   )
45 | }
46 | 
--------------------------------------------------------------------------------
/src/widgets/projectUsers/ProjectUsers.tsx:
--------------------------------------------------------------------------------
  1 | import React, {useCallback, useEffect, useState} from 'react'
  2 | import {from} from 'rxjs'
  3 | import {map, switchMap} from 'rxjs/operators'
  4 | import {Stack, Spinner, Box, Text, Button} from '@sanity/ui'
  5 | import {Role, useUserStore} from 'sanity'
  6 | import {useVersionedClient} from '../../versionedClient'
  7 | import {User} from 'sanity'
  8 | import {DashboardWidgetContainer} from '../../components/DashboardWidgetContainer'
  9 | import {ProjectUser} from './ProjectUser'
 10 | 
 11 | function getInviteUrl(projectId: string) {
 12 |   return `https://manage.sanity.io/projects/${projectId}/members`
 13 | }
 14 | 
 15 | interface Member {
 16 |   id: string
 17 |   roles: Role[]
 18 |   isRobot: boolean
 19 |   isCurrentUser: boolean
 20 |   createdAt: string
 21 | }
 22 | 
 23 | interface Project {
 24 |   id: string
 25 |   members: Member[]
 26 | }
 27 | 
 28 | export function ProjectUsers() {
 29 |   const [project, setProject] = useState()
 30 |   const [users, setUsers] = useState()
 31 |   const [error, setError] = useState()
 32 | 
 33 |   const userStore = useUserStore()
 34 |   const versionedClient = useVersionedClient()
 35 | 
 36 |   const fetchData = useCallback(() => {
 37 |     const {projectId} = versionedClient.config()
 38 |     const subscription = versionedClient.observable
 39 |       .request({
 40 |         uri: `/projects/${projectId}`,
 41 |         tag: 'dashboard.project-users',
 42 |       })
 43 |       .pipe(
 44 |         switchMap((_project) =>
 45 |           from(userStore.getUsers(_project.members.map((mem) => mem.id))).pipe(
 46 |             map((_users) => ({project: _project, users: _users})),
 47 |           ),
 48 |         ),
 49 |       )
 50 |       .subscribe({
 51 |         next: ({users: _users, project: _project}) => {
 52 |           setProject(_project)
 53 |           setUsers(
 54 |             (Array.isArray(_users) ? _users : [_users]).sort((userA, userB) =>
 55 |               sortUsersByRobotStatus(userA, userB, _project),
 56 |             ),
 57 |           )
 58 |         },
 59 |         error: (e: Error) => setError(e),
 60 |       })
 61 | 
 62 |     return () => subscription.unsubscribe()
 63 |   }, [userStore, versionedClient])
 64 | 
 65 |   useEffect(() => fetchData(), [fetchData])
 66 | 
 67 |   const handleRetryFetch = useCallback(() => fetchData(), [fetchData])
 68 | 
 69 |   const isLoading = !users || !project
 70 | 
 71 |   if (error) {
 72 |     return (
 73 |       
 74 |         
 75 |           
 76 |             Something went wrong while fetching data. You could{' '}
 77 |             
 78 |               retry
 79 |             
 80 |             ..?
 81 |           
 82 |         
 83 |       
 84 |     )
 85 |   }
 86 | 
 87 |   return (
 88 |     
102 |       }
103 |     >
104 |       {isLoading && (
105 |         
106 |           
107 |             
108 |               
109 |             
110 |             
111 |               Loading items…
112 |             
113 |           
114 |         
115 |       )}
116 | 
117 |       {!isLoading && (
118 |         
119 |           {users?.map((user) => {
120 |             const membership = project.members.find((member) => member.id === user.id)
121 |             return (
122 |                role.title) || []}
127 |               />
128 |             )
129 |           })}
130 |         
131 |       )}
132 |     
133 |   )
134 | }
135 | 
136 | function sortUsersByRobotStatus(userA: User, userB: User, project: Project) {
137 |   const {members} = project
138 |   const membershipA = members.find((member) => member.id === userA?.id)
139 |   const membershipB = members.find((member) => member.id === userB?.id)
140 | 
141 |   // On ties, sort by when the user was added
142 |   if (membershipA?.isRobot === membershipB?.isRobot) {
143 |     return (membershipA?.createdAt || '') > (membershipB?.createdAt || '') ? 1 : -1
144 |   }
145 | 
146 |   // Robots go to the bottom
147 |   return membershipA?.isRobot ? 1 : -1
148 | }
149 | 
--------------------------------------------------------------------------------
/src/widgets/projectUsers/index.ts:
--------------------------------------------------------------------------------
 1 | import {ProjectUsers} from './ProjectUsers'
 2 | import {LayoutConfig, DashboardWidget} from '../../types'
 3 | 
 4 | export function projectUsersWidget(config?: {layout?: LayoutConfig}): DashboardWidget {
 5 |   return {
 6 |     name: 'project-info',
 7 |     component: ProjectUsers,
 8 |     layout: config?.layout,
 9 |   }
10 | }
11 | 
--------------------------------------------------------------------------------
/src/widgets/sanityTutorials/SanityTutorials.tsx:
--------------------------------------------------------------------------------
 1 | import React, {useEffect, useState} from 'react'
 2 | import {Flex} from '@sanity/ui'
 3 | import {Tutorial} from './Tutorial'
 4 | import {FeedItem, Guide, useDataAdapter} from './dataAdapter'
 5 | import {DashboardWidgetContainer} from '../../components/DashboardWidgetContainer'
 6 | 
 7 | function createUrl(slug: {current: string}, type?: string) {
 8 |   if (type === 'tutorial') {
 9 |     return `https://www.sanity.io/docs/tutorials/${slug.current}`
10 |   } else if (type === 'guide') {
11 |     return `https://www.sanity.io/docs/guides/${slug.current}`
12 |   }
13 |   return false
14 | }
15 | 
16 | export interface SanityTutorialsProps {
17 |   templateRepoId: string
18 | }
19 | 
20 | export function SanityTutorials(props: SanityTutorialsProps) {
21 |   const {templateRepoId} = props
22 |   const [feedItems, setFeedItems] = useState([])
23 | 
24 |   const {getFeed, urlBuilder} = useDataAdapter()
25 | 
26 |   useEffect(() => {
27 |     const subscription = getFeed(templateRepoId).subscribe((response) => {
28 |       setFeedItems(response.items)
29 |     })
30 |     return () => {
31 |       subscription.unsubscribe()
32 |     }
33 |   }, [setFeedItems, getFeed, templateRepoId])
34 | 
35 |   const title = 'Learn about Sanity'
36 | 
37 |   return (
38 |     
39 |       
40 |         {feedItems?.map((feedItem, index) => {
41 |           if (!feedItem.title || (!feedItem.guideOrTutorial && !feedItem.externalLink)) {
42 |             return null
43 |           }
44 |           const presenter = feedItem.presenter || feedItem.guideOrTutorial?.presenter || {}
45 |           const subtitle = feedItem.category
46 |           const {guideOrTutorial = {} as Guide} = feedItem
47 |           const href =
48 |             (guideOrTutorial.slug
49 |               ? createUrl(guideOrTutorial.slug, guideOrTutorial._type)
50 |               : feedItem.externalLink) || feedItem.externalLink
51 | 
52 |           return (
53 |             
61 |               
71 |             
72 |           )
73 |         })}
74 |       
75 |     
76 |   )
77 | }
78 | 
--------------------------------------------------------------------------------
/src/widgets/sanityTutorials/Tutorial.tsx:
--------------------------------------------------------------------------------
  1 | import React from 'react'
  2 | import {Card, Box, Heading, Flex, Text, Stack} from '@sanity/ui'
  3 | import {PlayIcon} from '@sanity/icons'
  4 | import {styled} from 'styled-components'
  5 | 
  6 | const PlayIconBox = styled(Box)`
  7 |   position: absolute;
  8 |   top: 50%;
  9 |   left: 50%;
 10 |   transform: translate(-50%, -50%);
 11 | 
 12 |   &:before {
 13 |     content: '';
 14 |     position: absolute;
 15 |     top: 50%;
 16 |     left: 50%;
 17 |     transform: translate(-50%, -50%);
 18 |     width: 2.75em;
 19 |     height: 2.75em;
 20 |     border-radius: 50%;
 21 |     background: ${({theme}) => theme.sanity.color.card.enabled.bg};
 22 |     opacity: 0.75;
 23 |   }
 24 | `
 25 | 
 26 | const Root = styled(Flex)`
 27 |   &:hover {
 28 |     ${PlayIconBox} {
 29 |       &:before {
 30 |         opacity: 1;
 31 |       }
 32 |     }
 33 |   }
 34 | `
 35 | 
 36 | const PosterCard = styled(Card)`
 37 |   width: 100%;
 38 |   padding-bottom: calc(9 / 16 * 100%);
 39 |   position: relative;
 40 | `
 41 | 
 42 | const Poster = styled.img`
 43 |   position: absolute;
 44 |   top: 0;
 45 |   left: 0;
 46 |   height: 100%;
 47 |   width: 100%;
 48 |   object-fit: cover;
 49 |   display: block;
 50 | 
 51 |   &:not([src]) {
 52 |     display: none;
 53 |   }
 54 | `
 55 | 
 56 | export interface TutorialProps {
 57 |   title: string
 58 |   posterURL?: string
 59 |   href: string
 60 |   showPlayIcon?: boolean
 61 |   presenterName?: string
 62 |   presenterSubtitle?: string
 63 | }
 64 | 
 65 | export function Tutorial(props: TutorialProps) {
 66 |   const {title, posterURL, showPlayIcon, href, presenterName, presenterSubtitle} = props
 67 | 
 68 |   return (
 69 |     
 70 |       
 81 |         
 82 |           {posterURL && (
 83 |             
 84 |               
 85 |               {showPlayIcon && (
 86 |                 
 87 |                   
 88 |                     
 89 |                   
 90 |                 
 91 |               )}
 92 |             
 93 |           )}
 94 |           
 95 |             
 96 |               {title}
 97 |             
 98 |             
 99 |               
100 |                 {presenterName}
101 |                 
102 |                   {presenterSubtitle}
103 |                 
104 |               
105 |             
106 |           
107 |         
108 |       
109 |     
110 |   )
111 | }
112 | 
--------------------------------------------------------------------------------
/src/widgets/sanityTutorials/dataAdapter.ts:
--------------------------------------------------------------------------------
 1 | import {useMemo} from 'react'
 2 | import {useVersionedClient} from '../../versionedClient'
 3 | import imageUrlBuilder from '@sanity/image-url'
 4 | 
 5 | const tutorialsProjectConfig = {
 6 |   projectId: '3do82whm',
 7 |   dataset: 'next',
 8 | }
 9 | 
10 | export interface Guide {
11 |   _type?: string
12 |   slug?: {current: string}
13 |   presenter?: {
14 |     name?: string
15 |   }
16 | }
17 | 
18 | export interface FeedItem {
19 |   _id: string
20 |   title?: string
21 |   poster?: string
22 |   category?: string
23 |   guideOrTutorial?: Guide
24 |   externalLink?: string
25 |   presenter?: {
26 |     name?: string
27 |   }
28 |   hasVideo?: boolean
29 | }
30 | 
31 | export function useDataAdapter() {
32 |   const versionedClient = useVersionedClient()
33 |   return useMemo(
34 |     () => ({
35 |       getFeed: (templateRepoId: string) => {
36 |         const uri = templateRepoId
37 |           ? `/addons/dashboard?templateRepoId=${templateRepoId}`
38 |           : '/addons/dashboard'
39 |         return versionedClient.observable.request<{items: FeedItem[]}>({
40 |           uri,
41 |           tag: 'dashboard.sanity-tutorials',
42 |           withCredentials: false,
43 |         })
44 |       },
45 |       urlBuilder: imageUrlBuilder(tutorialsProjectConfig),
46 |     }),
47 |     [versionedClient],
48 |   )
49 | }
50 | 
--------------------------------------------------------------------------------
/src/widgets/sanityTutorials/index.ts:
--------------------------------------------------------------------------------
 1 | import {SanityTutorials} from './SanityTutorials'
 2 | import {LayoutConfig, DashboardWidget} from '../../types'
 3 | 
 4 | export function sanityTutorialsWidget(config?: {layout?: LayoutConfig}): DashboardWidget {
 5 |   return {
 6 |     name: 'sanity-tutorials',
 7 |     component: SanityTutorials,
 8 |     layout: config?.layout ?? {width: 'full'},
 9 |   }
10 | }
11 | 
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "extends": "./tsconfig.settings",
 3 |   "include": ["./src", "./package.config.ts"],
 4 |   "compilerOptions": {
 5 |     "rootDir": ".",
 6 |     "jsx": "react-jsx",
 7 |     "noEmit": true
 8 |   }
 9 | }
10 | 
--------------------------------------------------------------------------------
/tsconfig.lib.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "extends": "./tsconfig.settings",
 3 |   "include": ["./src"],
 4 |   "exclude": [
 5 |     "./src/**/__fixtures__",
 6 |     "./src/**/__mocks__",
 7 |     "./src/**/*.test.ts",
 8 |     "./src/**/*.test.tsx"
 9 |   ],
10 |   "compilerOptions": {
11 |     "rootDir": ".",
12 |     "outDir": "./lib",
13 |     "jsx": "preserve",
14 |     "emitDeclarationOnly": true,
15 |     "noEmit": true,
16 |     "module": "Preserve"
17 |   }
18 | }
19 | 
--------------------------------------------------------------------------------
/tsconfig.settings.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "compilerOptions": {
 3 |     "moduleResolution": "node",
 4 |     "target": "esnext",
 5 |     "module": "esnext",
 6 |     "lib": ["DOM", "DOM.Iterable", "ESNext"],
 7 |     "esModuleInterop": true,
 8 |     "strict": true,
 9 |     "downlevelIteration": true,
10 |     "declaration": true,
11 |     "allowSyntheticDefaultImports": true,
12 |     "skipLibCheck": true,
13 |     "isolatedModules": true
14 |   }
15 | }
16 | 
--------------------------------------------------------------------------------
/v2-incompatible.js:
--------------------------------------------------------------------------------
 1 | const {showIncompatiblePluginDialog} = require('@sanity/incompatible-plugin')
 2 | const {name, version, sanityExchangeUrl} = require('./package.json')
 3 | 
 4 | export default showIncompatiblePluginDialog({
 5 |   name: name,
 6 |   versions: {
 7 |     v3: version,
 8 |     v2: '^2.30.0',
 9 |   },
10 |   sanityExchangeUrl,
11 | })
12 | 
--------------------------------------------------------------------------------