├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .github └── workflows │ ├── deprecate.yml │ └── main.yml ├── .gitignore ├── .husky ├── commit-msg └── pre-commit ├── .npmignore ├── .npmrc ├── .prettierrc.json ├── .releaserc.json ├── CHANGELOG.md ├── LICENSE ├── PLUGIN_README.md ├── README.md ├── commitlint.config.js ├── lint-staged.config.js ├── package-lock.json ├── package.config.ts ├── package.json ├── renovate.json ├── sanity.json ├── src ├── components │ ├── dateInputs │ │ ├── CommonDateTimeInput.tsx │ │ ├── DateTimeInput.tsx │ │ ├── README.md │ │ ├── base │ │ │ ├── DatePicker.tsx │ │ │ ├── DateTimeInput.tsx │ │ │ ├── LazyTextInput.tsx │ │ │ └── calendar │ │ │ │ ├── Calendar.tsx │ │ │ │ ├── CalendarDay.tsx │ │ │ │ ├── CalendarMonth.tsx │ │ │ │ ├── YearInput.tsx │ │ │ │ ├── constants.ts │ │ │ │ ├── features.ts │ │ │ │ └── utils.ts │ │ ├── index.ts │ │ ├── types.ts │ │ └── utils.ts │ ├── dialogs │ │ ├── DialogFooter.tsx │ │ ├── DialogHeader.tsx │ │ ├── DialogScheduleEdit.tsx │ │ └── DialogTimeZone.tsx │ ├── documentWrapper │ │ ├── ScheduleBanner.tsx │ │ └── ScheduledDocumentInput.tsx │ ├── editScheduleForm │ │ ├── EditScheduleForm.tsx │ │ ├── ScheduleForm.tsx │ │ └── index.ts │ ├── errorCallout │ │ └── ErrorCallout.tsx │ ├── scheduleContextMenu │ │ ├── ContextMenuItems.tsx │ │ ├── FallbackContextMenu.tsx │ │ ├── MenuItemWithPermissionsTooltip.tsx │ │ ├── ScheduleContextMenu.tsx │ │ └── index.ts │ ├── scheduleItem │ │ ├── DocumentPreview.tsx │ │ ├── NoSchemaItem.tsx │ │ ├── PreviewWrapper.tsx │ │ ├── ScheduleItem.tsx │ │ ├── StateReasonFailedInfo.tsx │ │ ├── ToolPreview.tsx │ │ ├── User.tsx │ │ ├── dateWithTooltip │ │ │ ├── DateWithTooltip.tsx │ │ │ └── DateWithTooltipElementQuery.tsx │ │ ├── documentStatus │ │ │ ├── DraftStatus.tsx │ │ │ ├── PublishedStatus.tsx │ │ │ ├── README.md │ │ │ └── TimeAgo.tsx │ │ └── index.ts │ ├── timeZoneButton │ │ ├── TimeZoneButton.tsx │ │ └── TimeZoneButtonElementQuery.tsx │ ├── toastDescription │ │ └── ToastDescription.tsx │ └── validation │ │ ├── SchedulesValidation.tsx │ │ ├── ValidationInfo.tsx │ │ ├── ValidationList.tsx │ │ └── ValidationListItem.tsx ├── constants.tsx ├── contexts │ └── documentActionProps.tsx ├── documentActions.ts ├── documentActions │ └── schedule │ │ ├── NewScheduleInfo.tsx │ │ ├── ScheduleAction.tsx │ │ ├── Schedules.tsx │ │ └── index.ts ├── documentBadges.ts ├── documentBadges │ └── scheduled │ │ ├── ScheduledBadge.tsx │ │ └── index.ts ├── hooks │ ├── useCheckFeature.ts │ ├── useDialogScheduleEdit.ts │ ├── useDialogTimeZone.ts │ ├── useDialogVisibile.ts │ ├── useFilteredSchedules.ts │ ├── usePollSchedules.ts │ ├── usePreviewState.ts │ ├── usePublishedId.ts │ ├── useScheduleApi.ts │ ├── useScheduleForm.ts │ ├── useScheduleOperation.tsx │ ├── useSchemaType.ts │ ├── useTimeZone.tsx │ ├── useToolOptions.ts │ └── useValidations.ts ├── index.ts ├── inputResolver.tsx ├── tool │ ├── Tool.tsx │ ├── contexts │ │ └── schedules.tsx │ ├── featureBanner │ │ └── FeatureBanner.tsx │ ├── scheduleFilters │ │ ├── ScheduleFilter.tsx │ │ ├── ScheduleFilters.tsx │ │ └── index.ts │ ├── schedules │ │ ├── BigIconComingSoon.tsx │ │ ├── BigIconScreen.tsx │ │ ├── BigIconSuccess.tsx │ │ ├── EmptySchedules.tsx │ │ ├── Schedules.tsx │ │ ├── VirtualList.tsx │ │ ├── VirtualListItem.tsx │ │ └── index.ts │ ├── schedulesContextMenu │ │ └── SchedulesContextMenu.tsx │ └── toolCalendar │ │ ├── Calendar.tsx │ │ ├── CalendarDay.tsx │ │ ├── CalendarMonth.tsx │ │ ├── Pip.tsx │ │ ├── README.md │ │ ├── ToolCalendar.tsx │ │ ├── constants.ts │ │ ├── index.ts │ │ └── utils.ts ├── types.ts └── utils │ ├── debug.ts │ ├── getErrorMessage.ts │ ├── paneItemHelpers.tsx │ ├── scheduleUtils.ts │ ├── sortByExecuteDate.ts │ └── validationUtils.ts ├── tsconfig.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 | .eslintrc.js 2 | commitlint.config.js 3 | lib 4 | lint-staged.config.js 5 | package.config.ts 6 | *.js -------------------------------------------------------------------------------- /.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-function-return-type': 0, 27 | '@typescript-eslint/no-shadow': 'error', 28 | '@typescript-eslint/no-unused-vars': 1, 29 | 'no-shadow': 'off', 30 | 'react/display-name': 0, 31 | 'react/jsx-no-bind': 0, 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/workflows/deprecate.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Deprecate package on npm 3 | 4 | on: 5 | schedule: 6 | - cron: '0 0 * * *' 7 | workflow_dispatch: 8 | 9 | permissions: 10 | contents: read # for checkout 11 | 12 | jobs: 13 | deprecate-overlays: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/setup-node@v4 17 | with: 18 | node-version: lts/* 19 | - run: echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_DEPRECATE_TOKEN }}" > ~/.npmrc 20 | - run: > 21 | npm deprecate "@sanity/scheduled-publishing@1.x" "As of v3.39.0 of Sanity Studio, this plugin has been deprecated and the Scheduled Publishing functionality has been moved into the core studio package. Read more and learn how to update your configuration in the Sanity docs: https://www.sanity.io/docs/scheduled-publishing" || true 22 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: CI & Release 3 | 4 | on: 5 | # Build on pushes branches that have a PR (including drafts) 6 | pull_request: 7 | # Build on commits pushed to branches without a PR if it's in the allowlist 8 | push: 9 | branches: [main,studio-v2] 10 | # https://docs.github.com/en/actions/managing-workflow-runs/manually-running-a-workflow 11 | workflow_dispatch: 12 | inputs: 13 | release: 14 | description: Release new version 15 | required: true 16 | default: false 17 | type: boolean 18 | 19 | concurrency: 20 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 21 | cancel-in-progress: true 22 | 23 | permissions: 24 | contents: read # for checkout 25 | 26 | jobs: 27 | build: 28 | runs-on: ubuntu-latest 29 | name: Lint & Build 30 | steps: 31 | - uses: actions/checkout@v4 32 | - uses: actions/setup-node@v4 33 | with: 34 | cache: npm 35 | node-version: lts/* 36 | - run: npm ci 37 | # Linting can be skipped 38 | - run: npm run lint --if-present 39 | if: github.event.inputs.test != 'false' 40 | # But not the build script, as semantic-release will crash if this command fails so it makes sense to test it early 41 | - run: npm run prepublishOnly --if-present 42 | 43 | test: 44 | needs: build 45 | # The test matrix can be skipped, in case a new release needs to be fast-tracked and tests are already passing on main 46 | if: github.event.inputs.test != 'false' 47 | runs-on: ${{ matrix.os }} 48 | name: Node.js ${{ matrix.node }} / ${{ matrix.os }} 49 | strategy: 50 | # 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 51 | fail-fast: false 52 | matrix: 53 | # Run the testing suite on each major OS with the latest LTS release of Node.js 54 | os: [macos-latest, ubuntu-latest, windows-latest] 55 | node: [lts/*] 56 | # It makes sense to also test the oldest, and latest, versions of Node.js, on ubuntu-only since it's the fastest CI runner 57 | include: 58 | - os: ubuntu-latest 59 | # Test the oldest LTS release of Node that's still receiving bugfixes and security patches, versions older than that have reached End-of-Life 60 | node: lts/-2 61 | - os: ubuntu-latest 62 | # Test the actively developed version that will become the latest LTS release next October 63 | node: current 64 | steps: 65 | # It's only necessary to do this for windows, as mac and ubuntu are sane OS's that already use LF 66 | - name: Set git to use LF 67 | if: matrix.os == 'windows-latest' 68 | run: | 69 | git config --global core.autocrlf false 70 | git config --global core.eol lf 71 | - uses: actions/checkout@v4 72 | - uses: actions/setup-node@v4 73 | with: 74 | cache: npm 75 | node-version: ${{ matrix.node }} 76 | - run: npm i 77 | - run: npm test --if-present 78 | 79 | release: 80 | permissions: 81 | id-token: write # to enable use of OIDC for npm provenance 82 | needs: [build, test] 83 | # only run if opt-in during workflow_dispatch 84 | if: inputs.release == true 85 | runs-on: ubuntu-latest 86 | name: Semantic release 87 | steps: 88 | - uses: actions/create-github-app-token@v1 89 | id: app-token 90 | with: 91 | app-id: ${{ secrets.ECOSPARK_APP_ID }} 92 | private-key: ${{ secrets.ECOSPARK_APP_PRIVATE_KEY }} 93 | - uses: actions/checkout@v4 94 | with: 95 | # Need to fetch entire commit history to 96 | # analyze every commit since last release 97 | fetch-depth: 0 98 | # Uses generated token to allow pushing commits back 99 | token: ${{ steps.app-token.outputs.token }} 100 | # Make sure the value of GITHUB_TOKEN will not be persisted in repo's config 101 | persist-credentials: false 102 | - uses: actions/setup-node@v4 103 | with: 104 | cache: npm 105 | node-version: lts/* 106 | - run: npm ci 107 | # Branches that will release new versions are defined in .releaserc.json 108 | - run: npx semantic-release 109 | # Don't allow interrupting the release step if the job is cancelled, as it can lead to an inconsistent state 110 | # e.g. git tags were pushed but it exited before `npm publish` 111 | if: always() 112 | env: 113 | NPM_CONFIG_PROVENANCE: true 114 | GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} 115 | NPM_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }} 116 | -------------------------------------------------------------------------------- /.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 | # Compiled plugin 57 | lib 58 | 59 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | /test 2 | /coverage 3 | .editorconfig 4 | .eslintrc 5 | .gitignore 6 | .github 7 | .prettierrc 8 | .travis.yml 9 | .nyc_output 10 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | legacy-peer-deps=true -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "printWidth": 100, 4 | "bracketSpacing": false, 5 | "singleQuote": true, 6 | "useTabs": false, 7 | "endOfLine": "lf" 8 | } 9 | -------------------------------------------------------------------------------- /.releaserc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@sanity/semantic-release-preset", 3 | "branches": ["main", {"name": "studio-v2", "channel": "studio-v2", "range": "0.x.x"}] 4 | } 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 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 | > [!IMPORTANT] 2 | > As of [v3.39.0](https://www.sanity.io/changelog/e6013ee5-8214-4e03-9593-f7b19124b8a3) of Sanity Studio, this plugin has been deprecated and the Scheduled Publishing functionality has been moved into the core studio package. 3 | > Read more and learn how to update your configuration in the [Sanity docs](https://www.sanity.io/docs/scheduled-publishing). 4 | > 5 | > Original README preserved [here](./PLUGIN_README.md) 6 | 7 | --- 8 | 9 | 10 | ## Migrate 11 | 12 | ### Remove plugin config 13 | 14 | Upgrade Sanity and run the following command in your project root to uninstall the plugin: 15 | 16 | ```sh 17 | npm install sanity@latest 18 | npm uninstall @sanity/scheduled-publishing 19 | ``` 20 | 21 | Next, remove the plugin from your studio configuration. Typically you'll find this in `./sanity.config.ts|js`. Find and delete the following lines from your configuration: 22 | 23 | ```diff 24 | // ./sanity.config.ts|js 25 | 26 | -import {scheduledPublishing} from '@sanity/scheduled-publishing' 27 | 28 | export default defineConfig({ 29 | // ... 30 | plugins: [ 31 | - scheduledPublishing() 32 | ], 33 | }) 34 | ``` 35 | 36 | Your plugin declaration might be a bit more expansive if you've defined a custom time format for the plugin. Delete it all! 37 | 38 | ```diff 39 | // ./sanity.config.ts|js 40 | 41 | import {scheduledPublishing} from '@sanity/scheduled-publishing' 42 | 43 | export default defineConfig({ 44 | // ... 45 | plugins: [ 46 | - scheduledPublishing({ 47 | - inputDateTimeFormat: 'MM/dd/yyyy h:mm a', 48 | - }), 49 | ], 50 | }) 51 | ``` 52 | 53 | ### Add new configuration for Scheduled Publishing 54 | 55 | Note that while very similar to the plugin config this goes into the top-level of your studio configuration. Setting enabled to false will opt you out of using scheduled publishing for the project. 56 | 57 | ```diff 58 | // ./sanity.config.ts|js 59 | 60 | + import {defineConfig} from 'sanity' 61 | 62 | defineConfig({ 63 | // .... 64 | + scheduledPublishing: { 65 | + enabled: true, 66 | + inputDateTimeFormat: 'MM/dd/yyyy h:mm a', 67 | + } 68 | ) 69 | ``` 70 | 71 | As before, you can add a custom time format if you so wish. If left unspecified, the format will default to `dd/MM/yyyy HH:mm`. 72 | 73 | ### Document actions and badges 74 | 75 | They are now exported from `sanity` 76 | 77 | ```diff 78 | -import {ScheduleAction, ScheduledBadge} from '@sanity/scheduled-publishing' 79 | + import {ScheduleAction, ScheduledBadge} from 'sanity' 80 | 81 | 82 | ``` 83 | -------------------------------------------------------------------------------- /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 | dist: 'lib', 5 | minify: true, 6 | legacyExports: true, 7 | // Remove this block to enable strict export validation 8 | extract: { 9 | rules: { 10 | 'ae-forgotten-export': 'off', 11 | 'ae-incompatible-release-tags': 'off', 12 | 'ae-internal-missing-underscore': 'off', 13 | 'ae-missing-release-tag': 'off', 14 | }, 15 | }, 16 | }) 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@sanity/scheduled-publishing", 3 | "version": "1.4.1", 4 | "description": "", 5 | "keywords": [ 6 | "sanity", 7 | "plugin" 8 | ], 9 | "homepage": "https://github.com/sanity-io/sanity-plugin-scheduled-publishing#readme", 10 | "bugs": { 11 | "url": "https://github.com/sanity-io/sanity-plugin-scheduled-publishing/issues" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git@github.com:sanity-io/sanity-plugin-scheduled-publishing.git" 16 | }, 17 | "license": "MIT", 18 | "author": "Sanity.io ", 19 | "exports": { 20 | ".": { 21 | "types": "./lib/src/index.d.ts", 22 | "source": "./src/index.ts", 23 | "import": "./lib/index.esm.js", 24 | "require": "./lib/index.js", 25 | "default": "./lib/index.esm.js" 26 | }, 27 | "./package.json": "./package.json" 28 | }, 29 | "main": "./lib/index.js", 30 | "module": "./lib/index.esm.js", 31 | "source": "./src/index.ts", 32 | "types": "./lib/src/index.d.ts", 33 | "files": [ 34 | "src", 35 | "lib", 36 | "v2-incompatible.js", 37 | "sanity.json" 38 | ], 39 | "scripts": { 40 | "prebuild": "npm run clean && plugin-kit verify-package --silent && pkg-utils", 41 | "build": "pkg-utils build --strict", 42 | "clean": "rimraf lib", 43 | "compile": "tsc --noEmit", 44 | "dev": "npm run watch", 45 | "format": "prettier src -w", 46 | "link-watch": "plugin-kit link-watch", 47 | "lint": "eslint .", 48 | "prepare": "husky install", 49 | "prepublishOnly": "npm run build", 50 | "release": "standard-version", 51 | "verify": "npm run compile && npm run lint", 52 | "watch": "pkg-utils watch" 53 | }, 54 | "dependencies": { 55 | "@sanity/color": "^2.1.20", 56 | "@sanity/icons": "^2.0.0", 57 | "@sanity/incompatible-plugin": "^1.0.4", 58 | "@sanity/ui": "^1.0.0", 59 | "@sanity/util": "^3.0.0", 60 | "@tanstack/react-virtual": "^3.0.1", 61 | "@vvo/tzdb": "^6.75.0", 62 | "date-fns": "^2.29.3", 63 | "date-fns-tz": "^2.0.0", 64 | "debug": "^4.3.4", 65 | "lodash": "^4.17.21", 66 | "pluralize": "^8.0.0", 67 | "react-focus-lock": "^2.9.1", 68 | "rxjs": "^7.5.7", 69 | "swr": "^1.3.0" 70 | }, 71 | "devDependencies": { 72 | "@commitlint/cli": "^17.2.0", 73 | "@commitlint/config-conventional": "^17.2.0", 74 | "@sanity/pkg-utils": "^1.17.3", 75 | "@sanity/plugin-kit": "^2.1.17", 76 | "@sanity/semantic-release-preset": "^4.1.2", 77 | "@types/debug": "^4.1.7", 78 | "@types/lodash": "^4.14.187", 79 | "@types/pluralize": "^0.0.29", 80 | "@types/react": "^18", 81 | "@types/react-dom": "^18", 82 | "@types/styled-components": "^5.1.26", 83 | "@typescript-eslint/eslint-plugin": "^5.42.0", 84 | "@typescript-eslint/parser": "^5.42.0", 85 | "eslint": "^8.26.0", 86 | "eslint-config-prettier": "^8.5.0", 87 | "eslint-config-sanity": "^6.0.0", 88 | "eslint-plugin-prettier": "^4.2.1", 89 | "eslint-plugin-react": "^7.31.10", 90 | "eslint-plugin-react-hooks": "^4.6.0", 91 | "husky": "^8.0.1", 92 | "lint-staged": "^13.0.3", 93 | "lodash": "^4.17.21", 94 | "prettier": "^2.7.1", 95 | "prettier-plugin-packagejson": "^2.3.0", 96 | "react": "^18", 97 | "react-dom": "^18", 98 | "rimraf": "^5.0.0", 99 | "sanity": "^3.0.0", 100 | "semantic-release": "^21.0.7", 101 | "typescript": "^4.8.4" 102 | }, 103 | "peerDependencies": { 104 | "@sanity/ui": "^1.0 || ^2.0", 105 | "react": "^18", 106 | "react-dom": "^18", 107 | "sanity": "^3.0.0", 108 | "styled-components": "^5.0 || ^6.0" 109 | }, 110 | "engines": { 111 | "node": ">=14" 112 | }, 113 | "publishConfig": { 114 | "access": "public", 115 | "provenance": true 116 | }, 117 | "sanityExchangeUrl": "https://www.sanity.io/plugins/scheduled-publishing" 118 | } 119 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "github>sanity-io/renovate-config", 5 | "github>sanity-io/renovate-config:studio-v3", 6 | ":reviewer(team:ecosystem)" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /sanity.json: -------------------------------------------------------------------------------- 1 | { 2 | "parts": [ 3 | { 4 | "implements": "part:@sanity/base/sanity-root", 5 | "path": "./v2-incompatible.js" 6 | } 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /src/components/dateInputs/CommonDateTimeInput.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-nested-ternary */ 2 | import {FormField} from 'sanity' 3 | import {TextInput, useForwardedRef} from '@sanity/ui' 4 | import React, {useEffect, useId, useMemo} from 'react' 5 | import useTimeZone from '../../hooks/useTimeZone' 6 | import {DateTimeInput} from './base/DateTimeInput' 7 | import {CommonProps, ParseResult} from './types' 8 | 9 | type Props = CommonProps & { 10 | title: string 11 | description?: string 12 | parseInputValue: (inputValue: string) => ParseResult 13 | formatInputValue: (date: Date) => string 14 | deserialize: (value: string) => ParseResult 15 | serialize: (date: Date) => string 16 | onChange: (nextDate: string | null) => void 17 | selectTime?: boolean 18 | placeholder?: string 19 | timeStep?: number 20 | customValidation?: (selectedDate: Date) => boolean 21 | } 22 | 23 | export const CommonDateTimeInput = React.forwardRef(function CommonDateTimeInput( 24 | props: Props, 25 | forwardedRef: React.ForwardedRef 26 | ) { 27 | const { 28 | value, 29 | markers, 30 | title, 31 | description, 32 | placeholder, 33 | parseInputValue, 34 | formatInputValue, 35 | deserialize, 36 | serialize, 37 | selectTime, 38 | timeStep, 39 | readOnly, 40 | level, 41 | onChange, 42 | customValidation, 43 | ...rest 44 | } = props 45 | 46 | const [localValue, setLocalValue] = React.useState(null) 47 | 48 | useEffect(() => { 49 | setLocalValue(null) 50 | }, [value]) 51 | 52 | const {zoneDateToUtc} = useTimeZone() 53 | const undefinedValue = typeof value === 'undefined' 54 | // Text input changes ('wall time') 55 | const handleDatePickerInputChange = React.useCallback( 56 | (event: React.FocusEvent) => { 57 | const nextInputValue = event.currentTarget.value 58 | const result = nextInputValue === '' ? null : parseInputValue(nextInputValue) 59 | 60 | if (result === null) { 61 | onChange(null) 62 | 63 | // If the field value is undefined, and we are clearing the invalid value 64 | // the above useEffect won't trigger, so we do some extra clean up here 65 | if (undefinedValue && localValue) { 66 | setLocalValue(null) 67 | } 68 | } else if (result.isValid) { 69 | // Convert zone time to UTC 70 | onChange(serialize(zoneDateToUtc(result.date))) 71 | } else { 72 | setLocalValue(nextInputValue) 73 | } 74 | }, 75 | [undefinedValue, zoneDateToUtc, localValue, serialize, onChange, parseInputValue] 76 | ) 77 | 78 | // Calendar changes (UTC) 79 | const handleDatePickerChange = React.useCallback( 80 | (nextDate: Date | null) => { 81 | onChange(nextDate ? serialize(nextDate) : null) 82 | }, 83 | [serialize, onChange] 84 | ) 85 | 86 | const inputRef = useForwardedRef(forwardedRef) 87 | 88 | const id = useId() 89 | 90 | const parseResult = localValue ? parseInputValue(localValue) : value ? deserialize(value) : null 91 | 92 | const inputValue = localValue 93 | ? localValue 94 | : parseResult?.isValid 95 | ? formatInputValue(parseResult.date) 96 | : value 97 | 98 | const nodeValidations = useMemo( 99 | () => 100 | markers.map((m) => ({ 101 | level: m.level, 102 | path: m.path, 103 | message: m.item.message, 104 | })), 105 | [markers] 106 | ) 107 | return ( 108 | 126 | {readOnly ? ( 127 | 128 | ) : ( 129 | 144 | )} 145 | 146 | ) 147 | }) 148 | -------------------------------------------------------------------------------- /src/components/dateInputs/DateTimeInput.tsx: -------------------------------------------------------------------------------- 1 | import {getMinutes, isValid, parse, parseISO, setMinutes} from 'date-fns' 2 | import {formatInTimeZone} from 'date-fns-tz' 3 | import React, {useCallback} from 'react' 4 | import {useToolOptions} from '../../hooks/useToolOptions' 5 | import useTimeZone from '../../hooks/useTimeZone' 6 | import {CommonDateTimeInput} from './CommonDateTimeInput' 7 | import {CommonProps, ParseResult} from './types' 8 | import {isValidDate} from './utils' 9 | 10 | type ParsedOptions = { 11 | calendarTodayLabel: string 12 | customValidation: (selectedDate: Date) => boolean 13 | customValidationMessage?: string 14 | timeStep: number 15 | } 16 | type SchemaOptions = { 17 | calendarTodayLabel?: string 18 | customValidation?: (selectedDate: Date) => boolean 19 | customValidationMessage?: string 20 | timeStep?: number 21 | } 22 | export type Props = CommonProps & { 23 | onChange: (date: string | null) => void 24 | type: { 25 | name: string 26 | title: string 27 | description?: string 28 | options?: SchemaOptions 29 | placeholder?: string 30 | } 31 | } 32 | 33 | function parseOptions(options: SchemaOptions = {}): ParsedOptions { 34 | return { 35 | customValidation: 36 | options.customValidation || 37 | function () { 38 | return true 39 | }, 40 | customValidationMessage: options.customValidationMessage || 'Invalid date.', 41 | calendarTodayLabel: options.calendarTodayLabel || 'Today', 42 | timeStep: ('timeStep' in options && Number(options.timeStep)) || 1, 43 | } 44 | } 45 | 46 | function serialize(date: Date) { 47 | return date.toISOString() 48 | } 49 | function deserialize(isoString: string): ParseResult { 50 | const deserialized = new Date(isoString) 51 | if (isValidDate(deserialized)) { 52 | return {isValid: true, date: deserialized} 53 | } 54 | return {isValid: false, error: `Invalid date value: "${isoString}"`} 55 | } 56 | 57 | // enforceTimeStep takes a dateString and datetime schema options and enforces the time 58 | // to be within the configured timeStep 59 | function enforceTimeStep(dateString: string, timeStep: number) { 60 | if (!timeStep || timeStep === 1) { 61 | return dateString 62 | } 63 | 64 | const date = parseISO(dateString) 65 | const minutes = getMinutes(date) 66 | const leftOver = minutes % timeStep 67 | if (leftOver !== 0) { 68 | return serialize(setMinutes(date, minutes - leftOver)) 69 | } 70 | 71 | return serialize(date) 72 | } 73 | 74 | export const DateTimeInput = React.forwardRef(function DateTimeInput( 75 | props: Props, 76 | forwardedRef: React.ForwardedRef 77 | ) { 78 | const {type, onChange, ...rest} = props 79 | const {title, description, placeholder} = type 80 | 81 | const {inputDateTimeFormat} = useToolOptions() 82 | 83 | const {getCurrentZoneDate, timeZone} = useTimeZone() 84 | 85 | const {customValidation, customValidationMessage, timeStep} = parseOptions(type.options) 86 | 87 | // Returns date in UTC string 88 | const handleChange = useCallback( 89 | (nextDate: string | null) => { 90 | let date = nextDate 91 | if (date !== null && timeStep > 1) { 92 | date = enforceTimeStep(date, timeStep) 93 | } 94 | 95 | onChange(date) 96 | }, 97 | [onChange, timeStep] 98 | ) 99 | 100 | const formatInputValue = React.useCallback( 101 | (date: Date) => formatInTimeZone(date, timeZone.name, `${inputDateTimeFormat}`), 102 | [inputDateTimeFormat, timeZone.name] 103 | ) 104 | 105 | const parseInputValue = React.useCallback( 106 | (inputValue: string) => { 107 | const parsed = parse(inputValue, `${inputDateTimeFormat}`, getCurrentZoneDate()) 108 | 109 | // Check is value is a valid date 110 | if (!isValid(parsed)) { 111 | return { 112 | isValid: false, 113 | error: `Invalid date. Must be in the format "${inputDateTimeFormat}"`, 114 | } as ParseResult 115 | } 116 | 117 | // Check if value adheres to custom validation rules 118 | if (!customValidation(parsed)) { 119 | return { 120 | isValid: false, 121 | error: customValidationMessage, 122 | } as ParseResult 123 | } 124 | 125 | return { 126 | isValid: true, 127 | date: parsed, 128 | } as ParseResult 129 | }, 130 | [customValidation, customValidationMessage, getCurrentZoneDate, inputDateTimeFormat] 131 | ) 132 | 133 | return ( 134 | 149 | ) 150 | }) 151 | -------------------------------------------------------------------------------- /src/components/dateInputs/README.md: -------------------------------------------------------------------------------- 1 | This folder contains a customised version Sanity Studio's [DateInput](https://github.com/sanity-io/sanity/tree/next/packages/%40sanity/form-builder/src/inputs/DateInputs) and accompanying calendar components. 2 | 3 | Some changes have been made to make both `` and its calendar component _time zone aware_: 4 | 5 | ## DateTimeInput 6 | 7 | - This continues to handle all dates in UTC, though it formats + parses values using the _current time zone_ 8 | - Date and time handling uses `date-fns` formatting (rather than moment - which the studio is moving away from anyway) 9 | - Added the `customValidation` option, a callback function used to validate whether certain date ranges are selectable in the calendar 10 | - Added the `customValidationMessage` option, a custom error message displayed when `customValidation` fails 11 | 12 | ```js 13 | // E.g. No scheduling on weekends! 14 | const {utcToCurrentZoneDate} = useTimeZone() 15 | 16 | const handleCustomValidation = (selectedDate: Date): boolean => { 17 | return !isWeekend(utcToCurrentZoneDate(selectedDate)) 18 | } 19 | 20 | return ( 21 | 31 | ) 32 | ``` 33 | 34 | ## DateTimeInput calendar 35 | 36 | - Ingested dates (e.g. `focusedDate` and `selectedDate` are now in 'wall time' – or time zone formatted dates). This is accomplished with extensive use of `date-fns-tz` helper functions. 37 | - All dates returned in callbacks (e.g. `onSelect` and `onFocusedDateChange`) **always return values in UTC** (for the corresponding `` to ingest). 38 | 39 | These changes ensure correct days / hours etc are highlighted in various calendar UI elements when switching between time zones. 40 | -------------------------------------------------------------------------------- /src/components/dateInputs/base/DatePicker.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import useTimeZone from '../../../hooks/useTimeZone' 3 | import {Calendar} from './calendar/Calendar' 4 | 5 | export const DatePicker = React.forwardRef(function DatePicker( 6 | props: Omit, 'onChange'> & { 7 | value?: Date 8 | onChange: (nextDate: Date) => void 9 | selectTime?: boolean 10 | timeStep?: number 11 | customValidation?: (selectedDate: Date) => boolean 12 | }, 13 | ref: React.ForwardedRef 14 | ) { 15 | const {utcToCurrentZoneDate} = useTimeZone() 16 | const {value = new Date(), onChange, customValidation, ...rest} = props 17 | const [focusedDate, setFocusedDay] = React.useState() 18 | 19 | const handleSelect = React.useCallback( 20 | (nextDate: Date) => { 21 | onChange(nextDate) 22 | setFocusedDay(undefined) 23 | }, 24 | [onChange] 25 | ) 26 | 27 | return ( 28 | 37 | ) 38 | }) 39 | -------------------------------------------------------------------------------- /src/components/dateInputs/base/DateTimeInput.tsx: -------------------------------------------------------------------------------- 1 | import React, {KeyboardEvent, useCallback} from 'react' 2 | import FocusLock from 'react-focus-lock' 3 | import { 4 | Box, 5 | Button, 6 | LayerProvider, 7 | Popover, 8 | useClickOutside, 9 | useForwardedRef, 10 | usePortal, 11 | } from '@sanity/ui' 12 | import {CalendarIcon} from '@sanity/icons' 13 | import {DatePicker} from './DatePicker' 14 | import {LazyTextInput} from './LazyTextInput' 15 | 16 | type Props = { 17 | value?: Date 18 | id?: string 19 | readOnly?: boolean 20 | selectTime?: boolean 21 | timeStep?: number 22 | customValidity?: string 23 | placeholder?: string 24 | onInputChange?: (event: React.FocusEvent) => void 25 | inputValue?: string 26 | onChange: (date: Date | null) => void 27 | customValidation?: (selectedDate: Date) => boolean 28 | } 29 | 30 | export const DateTimeInput = React.forwardRef(function DateTimeInput( 31 | props: Props, 32 | forwardedRef: React.ForwardedRef 33 | ) { 34 | const { 35 | value, 36 | inputValue, 37 | customValidation, 38 | onInputChange, 39 | onChange, 40 | selectTime, 41 | timeStep, 42 | ...rest 43 | } = props 44 | 45 | const [popoverRef, setPopoverRef] = React.useState(null) 46 | 47 | const inputRef = useForwardedRef(forwardedRef) 48 | const buttonRef = React.useRef(null) 49 | 50 | const [isPickerOpen, setPickerOpen] = React.useState(false) 51 | 52 | const portal = usePortal() 53 | 54 | useClickOutside(() => setPickerOpen(false), [popoverRef]) 55 | 56 | const handleDeactivation = useCallback(() => { 57 | inputRef.current?.focus() 58 | inputRef.current?.select() 59 | }, [inputRef]) 60 | 61 | const handleKeyUp = useCallback((e: KeyboardEvent) => { 62 | if (e.key === 'Escape') { 63 | setPickerOpen(false) 64 | } 65 | }, []) 66 | 67 | const handleClick = useCallback(() => setPickerOpen(true), []) 68 | 69 | const suffix = ( 70 | 71 | 43 | ) 44 | } 45 | 46 | export default ScheduleFilter 47 | -------------------------------------------------------------------------------- /src/tool/scheduleFilters/ScheduleFilters.tsx: -------------------------------------------------------------------------------- 1 | import {useRouter} from 'sanity/router' 2 | import {CheckmarkIcon, CloseIcon, SelectIcon} from '@sanity/icons' 3 | import {Box, Button, Flex, Label, Menu, MenuButton, MenuItem} from '@sanity/ui' 4 | import {format} from 'date-fns' 5 | import React from 'react' 6 | import {SCHEDULE_FILTERS, SCHEDULE_STATE_DICTIONARY} from '../../constants' 7 | import {useFilteredSchedules} from '../../hooks/useFilteredSchedules' 8 | import {useSchedules} from '../contexts/schedules' 9 | import ScheduleFilter from './ScheduleFilter' 10 | 11 | export interface ScheduleFiltersProps { 12 | onClearDate: () => void 13 | selectedDate?: Date 14 | } 15 | 16 | export const ScheduleFilters = (props: ScheduleFiltersProps) => { 17 | const {onClearDate, selectedDate} = props 18 | const {navigate} = useRouter() 19 | const {schedules, scheduleState} = useSchedules() 20 | 21 | const handleMenuClick = (state: Record) => { 22 | navigate(state) 23 | } 24 | 25 | const currentSchedules = useFilteredSchedules(schedules, scheduleState) 26 | 27 | return ( 28 | <> 29 | {/* Small breakpoints: Menu button */} 30 | 31 | {selectedDate && ( 32 |