├── .distignore
├── .editorconfig
├── .eslintrc.js
├── .gitattributes
├── .github
├── ISSUE_TEMPLATE
│ ├── Bug_report.yml
│ ├── Feature_request.yml
│ └── config.yml
├── PULL_REQUEST_TEMPLATE.md
├── actions
│ ├── node
│ │ └── action.yml
│ └── php
│ │ └── action.yml
├── dependabot.yml
└── workflows
│ ├── coding-standards.yml
│ ├── node-build.yml
│ ├── release.yml
│ └── test-suite.yml
├── .gitignore
├── .husky
└── pre-commit
├── .lintstagedrc
├── .nvmrc
├── .prettierrc.js
├── .stylelintrc
├── .wp-env.json
├── CONTRIBUTING.md
├── LICENSE.md
├── README.md
├── babel.config.js
├── composer.json
├── composer.lock
├── includes
├── channels.php
├── class-activator.php
├── class-channel-registry.php
├── class-uninstaller.php
├── database
│ └── class-schema.php
├── exceptions
│ ├── class-runtime-exception.php
│ └── interface-exception.php
├── factory
│ ├── class-message.php
│ ├── class-notification.php
│ └── class-subscription.php
├── framework
│ └── class-factory.php
├── helper
│ └── class-serde.php
├── image
│ ├── class-base-image.php
│ └── interface-image.php
├── interface-notification.php
├── interface-status.php
├── load.php
├── model
│ ├── class-channel.php
│ ├── class-message.php
│ ├── class-notification.php
│ └── class-subscription.php
├── persistence
│ ├── class-abstract-notification-repository.php
│ ├── class-wpdb-notification-repository.php
│ ├── interface-notification-repository.php
│ └── interface-order.php
└── restapi
│ ├── class-channel-controller.php
│ ├── class-notification-controller.php
│ └── class-subscription-controller.php
├── jest.config.js
├── languages
└── .gitkeep
├── package-lock.json
├── package.json
├── phpcs.xml.dist
├── phpunit.xml.dist
├── readme.txt
├── src
├── components
│ ├── drawer
│ │ └── index.tsx
│ ├── hub-icon
│ │ └── index.tsx
│ ├── notice-area
│ │ ├── footer
│ │ │ └── index.tsx
│ │ ├── index.tsx
│ │ └── section-header
│ │ │ └── index.tsx
│ ├── notice-empty
│ │ └── index.tsx
│ ├── notice-loop
│ │ └── index.tsx
│ ├── notice
│ │ ├── actions
│ │ │ └── index.tsx
│ │ ├── icon
│ │ │ └── index.tsx
│ │ ├── index.tsx
│ │ └── meta
│ │ │ └── index.tsx
│ ├── notification-hub
│ │ └── index.tsx
│ └── unread-dot
│ │ └── index.tsx
├── constants.ts
├── store
│ ├── actions.ts
│ ├── constants.ts
│ ├── controls.ts
│ ├── index.ts
│ ├── reducer.ts
│ ├── resolvers.ts
│ ├── selectors.ts
│ └── utils.ts
├── styles
│ ├── components
│ │ ├── bullet.scss
│ │ └── notification.scss
│ ├── dashboard
│ │ └── notice.scss
│ ├── hub
│ │ ├── admin-bar.scss
│ │ ├── elements.scss
│ │ ├── layout.scss
│ │ └── notice.scss
│ ├── vars.scss
│ └── wp-notifications.scss
├── types.ts
├── utils
│ ├── guards.ts
│ ├── index.ts
│ ├── init.tsx
│ └── sanitization.ts
└── wp-notifications.ts
├── storybook
├── .babelrc.json
├── .npmrc
├── .storybook
│ ├── main.js
│ ├── manager.js
│ ├── preview.js
│ └── wp-notifications-theme.js
├── fake_api.json
├── package.json
├── stories
│ ├── Dash-multiple.stories.jsx
│ ├── Dash-single.stories.jsx
│ ├── Hub-empty.stories.jsx
│ ├── Hub-multiple.stories.jsx
│ ├── Hub-single.stories.jsx
│ ├── Introduction.stories.mdx
│ ├── assets
│ │ ├── WordPressLogo.svg
│ │ ├── code.svg
│ │ ├── dashicons.css
│ │ ├── env.svg
│ │ ├── i.svg
│ │ ├── logo.afphoto
│ │ ├── logo.svg
│ │ ├── slack.svg
│ │ └── wp-core
│ │ │ ├── admin-bar.css
│ │ │ ├── admin-menu.css
│ │ │ ├── buttons.css
│ │ │ ├── common.css
│ │ │ ├── dashboard.css
│ │ │ ├── dashicons.css
│ │ │ ├── edit.css
│ │ │ ├── fonts
│ │ │ ├── dashicons.eot
│ │ │ ├── dashicons.svg
│ │ │ ├── dashicons.ttf
│ │ │ ├── dashicons.woff
│ │ │ └── dashicons.woff2
│ │ │ ├── images
│ │ │ ├── about-header-about.svg
│ │ │ ├── about-texture.png
│ │ │ ├── resize-2x.gif
│ │ │ ├── resize-rtl-2x.gif
│ │ │ ├── resize-rtl.gif
│ │ │ ├── resize.gif
│ │ │ ├── spinner-2x.gif
│ │ │ ├── spinner.gif
│ │ │ ├── stars-2x.png
│ │ │ └── stars.png
│ │ │ ├── nav-menus.css
│ │ │ ├── normalize.css
│ │ │ └── site-health.css
│ └── docs
│ │ ├── contributors
│ │ ├── databaseSchema.stories.mdx
│ │ └── develop.md
│ │ ├── database-schema.md
│ │ ├── databaseSchema.stories.mdx
│ │ ├── internal-api.md
│ │ ├── internalApi.stories.mdx
│ │ ├── rest-api.md
│ │ ├── restApi.stories.mdx
│ │ ├── translations.md
│ │ └── translations.stories.mdx
└── tsconfig.json
├── tests
├── bin
│ └── install-wp-tests.sh
├── jse2e
│ └── main.test.js
├── jsunit
│ └── main.test.js
└── phpunit
│ ├── includes
│ ├── bootstrap.php
│ ├── class-db-testcase.php
│ └── class-testcase.php
│ └── tests
│ ├── factory
│ ├── test-factory-message.php
│ ├── test-factory-notification.php
│ └── test-factory-subscription.php
│ ├── model
│ ├── test-model-channel.php
│ ├── test-model-message.php
│ ├── test-model-notification.php
│ └── test-model-subscription.php
│ ├── rest-api
│ ├── test-channel-controller.php
│ ├── test-notification-controller.php
│ └── test-subscription-controller.php
│ ├── test-activator.php
│ ├── test-base-image.php
│ ├── test-channel-registry.php
│ └── test-uninstaller.php
├── tsconfig.eslint.json
├── tsconfig.json
├── types
└── global.d.ts
├── webpack.config.js
└── wp-feature-notifications.php
/.distignore:
--------------------------------------------------------------------------------
1 | /.git
2 | .gitignore
3 | .gitattributes
4 |
5 | /.idea
6 | /.github
7 | /.storybook
8 | /.vscode
9 | /.wordpress-org
10 |
11 | /.husky
12 | /docs
13 | /node_modules
14 | /src
15 | /storybook
16 | /tests
17 | /types
18 | /vendor/bin
19 | /vendor/composer/installers
20 | /vendor/**/*.phar
21 | /wp-feature-notifications
22 |
23 | .distignore
24 | .editorconfig
25 | .eslintignore
26 | .eslintrc.js
27 | .gitattributes
28 | .gitignore
29 | .lintstagedrc
30 | .nvmrc
31 | .prettierignore
32 | .prettierrc.js
33 | .stylelintrc
34 | .wp-env.json
35 |
36 | babel.config.js
37 | composer.json
38 | composer.lock
39 | jest.config.js
40 | package-lock.json
41 | package.json
42 | phpcs.xml.dist
43 | phpunit.xml.dist
44 | CONTRIBUTING.md
45 | README.md
46 | readme.md
47 | tsconfig.eslint.json
48 | tsconfig.json
49 | webpack.config.js
50 | wp-feature-notifications.zip
51 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | # This file is for unifying the coding style for different editors and IDEs
2 | # editorconfig.org
3 |
4 | # WordPress Coding Standards
5 | # https://make.wordpress.org/core/handbook/coding-standards/
6 |
7 | root = true
8 |
9 | [*]
10 | charset = utf-8
11 | end_of_line = lf
12 | insert_final_newline = true
13 | trim_trailing_whitespace = true
14 | indent_style = tab
15 |
16 | [*.{yml,yaml}]
17 | indent_style = space
18 | indent_size = 2
19 |
20 | [*.{gradle,java,kt}]
21 | indent_style = space
22 |
23 | [packages/react-native-*/**.xml]
24 | indent_style = space
25 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | /**
2 | * ESLint presets
3 | */
4 | module.exports = {
5 | root: true,
6 | parser: '@typescript-eslint/parser',
7 | parserOptions: {
8 | project: [ './tsconfig.json', 'tsconfig.eslint.json' ],
9 | },
10 | extends: [ 'plugin:@wordpress/eslint-plugin/recommended' ],
11 | rules: {
12 | 'import/order': [
13 | 'error',
14 | {
15 | alphabetize: {
16 | order: 'asc',
17 | caseInsensitive: true,
18 | },
19 | 'newlines-between': 'always',
20 | groups: [ 'builtin', 'external', 'parent', 'sibling', 'index' ],
21 | pathGroups: [
22 | {
23 | pattern: '@wordpress/**',
24 | group: 'external',
25 | },
26 | ],
27 | pathGroupsExcludedImportTypes: [ 'builtin' ],
28 | },
29 | ],
30 | },
31 | overrides: [
32 | {
33 | files: 'tests/**/*',
34 | rules: {
35 | 'no-undef': 'off',
36 | },
37 | },
38 | ],
39 | settings: {
40 | 'import/parsers': {
41 | '@typescript-eslint/parser': [ '.js', '.jsx', '.ts', '.tsx' ],
42 | },
43 | 'import/resolver': {
44 | typescript: {
45 | alwaysTryTypes: true,
46 | project: [ './tsconfig.json', 'tsconfig.eslint.json' ],
47 | },
48 | },
49 | },
50 | env: {
51 | browser: true,
52 | es2021: true,
53 | jest: true,
54 | },
55 | };
56 |
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | # exclude vendored and generated files
2 | package-lock.json linguist-generated
3 | /storybook/stories/assets/** linguist-vendored
4 | /docs/** linguist-documentation
5 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/Bug_report.yml:
--------------------------------------------------------------------------------
1 | name: Bug report
2 | description: Report a bug with WP Feature Notifications
3 | body:
4 | - type: markdown
5 | attributes:
6 | value: |
7 | Thanks for taking the time to fill out this bug report! If this is a security issue, please report it in HackerOne instead: https://hackerone.com/wordpress
8 | - type: textarea
9 | attributes:
10 | label: Description
11 | description: Please write a brief description of the bug, including what you expect to happen and what is currently happening.
12 | placeholder: |
13 | Feature '...' is not working properly. I expect '...' to happen, but '...' happens instead
14 | validations:
15 | required: true
16 |
17 | - type: textarea
18 | attributes:
19 | label: Step-by-step reproduction instructions
20 | description: Please write the steps needed to reproduce the bug.
21 | placeholder: |
22 | 1. Go to '...'
23 | 2. Click on '...'
24 | 3. Scroll down to '...'
25 | validations:
26 | required: true
27 |
28 | - type: textarea
29 | attributes:
30 | label: Screenshots, screen recording, code snippet
31 | description: |
32 | If possible, please upload a screenshot or screen recording which demonstrates the bug. You can use LIEcap to create a GIF screen recording: https://www.cockos.com/licecap/
33 | Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in.
34 | If this bug is to related to a developer API, please share a code snippet that demonstrates the issue. For small snippets paste it directly here, or you can use GitHub Gist to share multiple code files: https://gist.github.com
35 | Please ensure the shared code can be used by a developer to reproduce the issue — ideally it can be copied into a local development environment or executed in a browser console to help debug the issue.
36 | validations:
37 | required: false
38 |
39 | - type: textarea
40 | attributes:
41 | label: Environment info
42 | description: |
43 | Please list what WP Feature Notifications version you are using.
44 | placeholder: |
45 | - WordPress version, WP Feature Notifications version, and active Theme you are using.
46 | - Browser(s) are you seeing the problem on.
47 | - Device you are using and operating system (e.g. "Desktop with Windows 10", "iPhone with iOS 14", etc.).
48 | - Any other active plugins and your hosting environment (if applicable)
49 | validations:
50 | required: false
51 |
52 | - type: dropdown
53 | id: existing
54 | attributes:
55 | label: Please confirm that you have searched existing issues in the repo.
56 | description: You can do this by searching https://github.com/WordPress/wp-feature-notifications/issues and making sure the bug is not related to another plugin.
57 | multiple: true
58 | options:
59 | - 'Yes'
60 | - 'No'
61 | validations:
62 | required: true
63 |
64 | - type: dropdown
65 | id: plugins
66 | attributes:
67 | label: Please confirm that you have tested with all plugins deactivated except WP Feature Notifications.
68 | multiple: true
69 | options:
70 | - 'Yes'
71 | - 'No'
72 | validations:
73 | required: true
74 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/Feature_request.yml:
--------------------------------------------------------------------------------
1 | name: Feature request
2 | description: Propose an idea for a feature or an enhancement
3 | body:
4 | - type: textarea
5 | attributes:
6 | label: What problem does this address?
7 | description: Please describe if this feature or enhancement is related to a current problem or pain point. For example, "I'm always frustrated when ..." or "It is currently difficult to ...".
8 | placeholder: |
9 | It is currently difficult to ...
10 | validations:
11 | required: true
12 |
13 | - type: textarea
14 | attributes:
15 | label: What is your proposed solution?
16 | description: Please outline the feature or enhancement that you want and how it addresses any problem identified above.
17 | validations:
18 | required: true
19 |
20 | - type: dropdown
21 | id: existing
22 | attributes:
23 | label: Please confirm that you have searched existing issues in the repo.
24 | description: You can do this by searching https://github.com/WordPress/wp-feature-notifications/issues.
25 | multiple: true
26 | options:
27 | - 'Yes'
28 | - 'No'
29 | validations:
30 | required: true
31 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/config.yml:
--------------------------------------------------------------------------------
1 | blank_issues_enabled: true
2 |
--------------------------------------------------------------------------------
/.github/PULL_REQUEST_TEMPLATE.md:
--------------------------------------------------------------------------------
1 |
3 |
4 | ## What?
5 |
6 |
7 | ## Why?
8 |
10 |
11 | ## How?
12 |
13 |
14 | ## Testing Instructions
15 |
16 |
17 |
18 |
19 |
20 | ## Screenshots or screencast
21 |
--------------------------------------------------------------------------------
/.github/actions/node/action.yml:
--------------------------------------------------------------------------------
1 | name: Setup Node
2 | description: Setup Node.js to a specific version and cache dependencies
3 |
4 | inputs:
5 | node-version:
6 | description: 'Node.js version the action should use'
7 | required: true
8 | default: '16'
9 |
10 | runs:
11 | using: 'composite'
12 | steps:
13 | - name: Setup node
14 | uses: actions/setup-node@v3
15 | with:
16 | node-version: ${{ inputs.node-version }}
17 |
18 | - name: Get npm cache directory
19 | id: npm-cache-dir
20 | shell: bash
21 | run: echo "dir=$(npm config get cache)" >> ${GITHUB_OUTPUT}
22 |
23 | - uses: actions/cache@v3
24 | id: npm-cache # use this to check for `cache-hit` ==> if: steps.npm-cache.outputs.cache-hit != 'true'
25 | with:
26 | path: ${{ steps.npm-cache-dir.outputs.dir }}
27 | key: ${{ runner.os }}-node-${{ inputs.node-version }}-${{ hashFiles('**/package-lock.json') }}
28 | restore-keys: |
29 | ${{ runner.os }}-node-${{ inputs.node-version }}
30 |
31 | - name: Install Packages
32 | shell: bash
33 | run: npm ci
34 |
--------------------------------------------------------------------------------
/.github/actions/php/action.yml:
--------------------------------------------------------------------------------
1 | name: Setup PHP
2 |
3 | runs:
4 | using: 'composite'
5 | steps:
6 | - name: Setup PHP
7 | uses: shivammathur/setup-php@v2
8 | with:
9 | php-version: ${{ matrix.php }}
10 | tools: composer
11 | extensions: mysql
12 | coverage: none
13 |
14 | - name: Get Composer cache directory
15 | id: composer-cache
16 | shell: bash
17 | run: echo "dir=$(composer config cache-files-dir)" >> ${GITHUB_OUTPUT}
18 |
19 | - name: Cache Composer packages
20 | uses: actions/cache@v3
21 | with:
22 | path: ${{ steps.composer-cache.outputs.dir }}
23 | key: ${{ runner.os }}-php-${{ matrix.php }}-${{ hashFiles('**/composer.lock') }}
24 | restore-keys: |
25 | ${{ runner.os }}-php-${{ matrix.php }}-
26 |
27 | - name: Install dependencies
28 | shell: bash
29 | run: composer install --prefer-dist --no-progress
30 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 |
3 | updates:
4 | - package-ecosystem: 'composer'
5 | directory: '/'
6 | schedule:
7 | interval: 'weekly'
8 | day: 'thursday'
9 | versioning-strategy: lockfile-only
10 | open-pull-requests-limit: 10
11 | commit-message:
12 | prefix: 'Composer'
13 | allow:
14 | - dependency-type: 'direct'
15 | groups:
16 | phpunit:
17 | patterns:
18 | - 'phpunit/phpunit'
19 | - 'wp-phpunit/wp-phpunit'
20 | - 'yoast/phpunit-polyfills'
21 |
22 | - package-ecosystem: 'npm'
23 | directory: '/'
24 | schedule:
25 | interval: 'weekly'
26 | day: 'thursday'
27 | versioning-strategy: lockfile-only
28 | open-pull-requests-limit: 10
29 | commit-message:
30 | prefix: 'npm'
31 | allow:
32 | - dependency-type: 'direct'
33 | - dependency-name: '@wordpress/*'
34 | dependency-type: 'all'
35 | groups:
36 | wordpress:
37 | patterns:
38 | - '@wordpress/*'
39 | storybook:
40 | patterns:
41 | - '@storybook/*'
42 |
--------------------------------------------------------------------------------
/.github/workflows/coding-standards.yml:
--------------------------------------------------------------------------------
1 | name: Coding Standards
2 |
3 | on:
4 | pull_request:
5 | branches: [develop, trunk]
6 | workflow_dispatch:
7 |
8 | jobs:
9 | lint:
10 | name: Check Coding Standards
11 | runs-on: ubuntu-latest
12 | steps:
13 | - name: Checkout
14 | uses: actions/checkout@v3
15 |
16 | - name: Setup Node.js
17 | uses: ./.github/actions/node
18 |
19 | - name: JS check
20 | shell: bash
21 | run: npm run lint:js
22 |
23 | - name: WPCS check
24 | uses: 10up/wpcs-action@stable
25 | with:
26 | enable_warnings: true
27 | use_local_config: true
28 |
--------------------------------------------------------------------------------
/.github/workflows/node-build.yml:
--------------------------------------------------------------------------------
1 | name: JS Continuous Integration
2 |
3 | on:
4 | pull_request:
5 | branches: [develop, trunk]
6 | workflow_dispatch:
7 |
8 | jobs:
9 | build:
10 | name: Build plugin
11 | runs-on: ${{ matrix.os }}
12 | strategy:
13 | matrix:
14 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/
15 | node-version: [16.x, 18.x]
16 | os: [ubuntu-latest, macos-latest, windows-latest]
17 |
18 | steps:
19 | - name: Checkout
20 | uses: actions/checkout@v3
21 |
22 | - name: Setup Node.js
23 | uses: ./.github/actions/node
24 | with:
25 | node-version: ${{ matrix.node-version }}
26 |
27 | - name: Build package
28 | shell: bash
29 | run: npm run build --if-present
30 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release
2 |
3 | on:
4 | push:
5 | tags:
6 | - '*.*.*'
7 |
8 | env:
9 | PLUGIN_SLUG: wp-feature-notifications
10 |
11 | permissions:
12 | contents: write
13 |
14 | jobs:
15 | release:
16 | name: Create Release
17 | runs-on: ubuntu-latest
18 |
19 | steps:
20 | - name: Checkout
21 | uses: actions/checkout@v3
22 |
23 | - name: Setup Node.js
24 | uses: ./.github/actions/node
25 |
26 | - name: Build package
27 | shell: bash
28 | run: npm run build --if-present
29 |
30 | - name: Create artifacts
31 | shell: bash
32 | run: npm run plugin-zip
33 |
34 | - name: Release
35 | uses: softprops/action-gh-release@v1
36 | with:
37 | name: github.ref
38 | files: ${{ env.PLUGIN_SLUG }}.zip
39 | fail_on_unmatched_files: true
40 | target_commitish: trunk
41 | generate_release_notes: true
42 | draft: true
43 |
44 | build-docs:
45 | name: Deploy documentation to GitHub Pages
46 | runs-on: ubuntu-latest
47 | needs: release
48 |
49 | steps:
50 | - name: Checkout
51 | uses: actions/checkout@v3
52 |
53 | - name: Setup Node.js
54 | uses: ./.github/actions/node
55 |
56 | - name: Build Storybook
57 | shell: bash
58 | run: npm run build:storybook
59 |
60 | - name: Deploy
61 | uses: peaceiris/actions-gh-pages@64b46b4226a4a12da2239ba3ea5aa73e3163c75b # v3.9.1
62 | with:
63 | github_token: ${{ secrets.GITHUB_TOKEN }}
64 | publish_dir: ./docs
65 | force_orphan: true
66 |
--------------------------------------------------------------------------------
/.github/workflows/test-suite.yml:
--------------------------------------------------------------------------------
1 | name: Test Suite
2 |
3 | on:
4 | pull_request:
5 | branches: [develop, trunk]
6 | workflow_dispatch:
7 |
8 | jobs:
9 | test:
10 | name: PHP ${{ matrix.php }} WP ${{ matrix.wp }}
11 | timeout-minutes: 15
12 | runs-on: ubuntu-latest
13 | env:
14 | WP_TESTS_DIR: /tmp/wordpress-tests-lib
15 | strategy:
16 | fail-fast: false
17 | matrix:
18 | php: ['7.4', '8.0', '8.1', '8.2']
19 | wp: ['latest']
20 | services:
21 | database:
22 | image: mysql:5.6
23 | env:
24 | MYSQL_ROOT_PASSWORD: wordpress
25 | ports:
26 | - 3306:3306
27 | options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=5
28 |
29 | steps:
30 | - name: Checkout code
31 | uses: actions/checkout@v3
32 |
33 | - name: Setup Node.js
34 | uses: ./.github/actions/node
35 |
36 | - name: Setup PHP
37 | uses: ./.github/actions/php
38 |
39 | - name: Install WordPress and initialize database
40 | run: ./tests/bin/install-wp-tests.sh wp_notify_tests root wordpress 127.0.0.1 latest
41 |
42 | - name: Run PHP Unit tests
43 | run: composer run test
44 |
45 | - name: Starting the WordPress Environment
46 | run: npx wp-env start
47 |
48 | - name: Running the JavaScript tests
49 | run: npm run test:js
50 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # OS fixes
2 | .DS_Store
3 |
4 | # IDE-specific
5 | .phpunit.result.cachecghooks.lock
6 |
7 | # Vendor code
8 | /node_modules/
9 | /vendor/
10 |
11 | # Build Files
12 | /build/
13 | /docs/
14 |
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 | . "$(dirname -- "$0")/_/husky.sh"
3 |
4 | npx lint-staged
5 |
--------------------------------------------------------------------------------
/.lintstagedrc:
--------------------------------------------------------------------------------
1 | {
2 | "**/*.(json|yml|yaml)": "prettier --write",
3 | "**/*.(css|scss|sass)": "stylelint --fix",
4 | "**/*.(js|jsx|cjs|mjs|ts|tsx)": "eslint --ext .js,.jsx,.cjs,.mjs,.ts,.tsx --fix",
5 | "**/*.php": "php vendor/bin/phpcbf --standard=phpcs.xml.dist -s --report=summary"
6 | }
7 |
--------------------------------------------------------------------------------
/.nvmrc:
--------------------------------------------------------------------------------
1 | lts/gallium
--------------------------------------------------------------------------------
/.prettierrc.js:
--------------------------------------------------------------------------------
1 | // Import the default config file and expose it in the project root.
2 | // Useful for editor integrations.
3 | module.exports = require( '@wordpress/prettier-config' );
4 |
--------------------------------------------------------------------------------
/.stylelintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": [ "@wordpress/stylelint-config/scss" ]
3 | }
4 |
--------------------------------------------------------------------------------
/.wp-env.json:
--------------------------------------------------------------------------------
1 | {
2 | "plugins": [ "." ]
3 | }
4 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing
2 |
3 | We would love your input! We want to make contributing to this project as easy and transparent as possible, whether it’s:
4 |
5 | - Reporting a bug
6 | - Testing the plugin
7 | - Discussing the current state, features, improvements
8 | - Submitting a fix or a new feature
9 |
10 | We use GitHub to host code, to track issues and feature requests, as well as accept pull requests.
11 |
12 | ## Philosophy and License
13 |
14 | WP Feature Notifications is an open source project, building on the WordPress software and the efforts of many past contributors.
15 |
16 | Its end goal is to become a part of WordPress core, and as such it is recommended to familiarise yourself with the [WordPress Core handbook](https://make.wordpress.org/core/handbook/)
17 |
18 | By contributing, you agree that your contributions will be licensed under its [GPLv2 License](LICENSE.md).
19 |
20 | ## Code of Conduct
21 |
22 | As a WordPress project, WP Feature Notifications follows the [WordPress Etiquette principles](https://wordpress.org/about/etiquette/), reproduced below:
23 |
24 | > In the WordPress open source project, we realize that our biggest asset is the community that we foster. The project, as a whole, follows these basic philosophical principles from The Cathedral and The Bazaar.
25 | > - Contributions to the WordPress open source project are for the benefit of the WordPress community as a whole, not specific businesses or individuals. All actions taken as a contributor should be made with the best interests of the community in mind.
26 | > - Participation in the WordPress open source project is open to all who wish to join, regardless of ability, skill, financial status, or any other criteria.
27 | > - The WordPress open source project is a volunteer-run community. Even in cases where contributors are sponsored by companies, that time is donated for the benefit of the entire open source community.
28 | > - Any member of the community can donate their time and contribute to the project in any form including design, code, documentation, community building, etc. For more information, go to [make.wordpress.org](https://make.wordpress.org).
29 | > - The WordPress open source community cares about diversity. We strive to maintain a welcoming environment where everyone can feel included, by keeping communication free of discrimination, incitement to violence, promotion of hate, and unwelcoming behavior.
30 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # WordPress Feature Project - Notifications
2 |
3 | > A feature plugin for WordPress, which aims to create a new (better) way to manage and deliver notifications to the relevant audience.
4 |
5 | - Contributors: schlessera, psykro, raaaahman, danbilauca, Sephsekla, erikyo, JasonTheAdams, johnhooks
6 | - Tags: feature-notifications
7 | - Requires at least: 6.2
8 | - Tested up to: 6.2
9 | - Requires PHP: 7.4
10 | - License: GPLv2 or later
11 | - License URI: https://www.gnu.org/licenses/gpl-2.0.html
12 |
13 | See also [Trac ticket #43484](https://core.trac.wordpress.org/ticket/43484).
14 |
15 | ## Contributing to the project
16 |
17 | Want to get involved? Join our weekly office hours every Wednesday at 15:00 UTC in the [#feature-notifications](https://wordpress.slack.com/messages/C2K1C71FE) channel of the [Make WordPress Slack](https://make.wordpress.org/chat/).
18 |
19 | Please be sure to read our [contribution guidelines](CONTRIBUTING.md) before getting started.
20 |
21 | ### Prerequisites
22 |
23 | - [NodeJS](https://nodejs.org/en/download/)
24 | - [Composer](https://getcomposer.org/download/)
25 | - [wp-env](https://developer.wordpress.org/block-editor/reference-guides/packages/packages-env/) (optional)
26 | - [Docker](https://docs.docker.com/get-docker/) (optional)
27 |
28 | We recommend using [nvm](https://github.com/nvm-sh/nvm) to ensure a compatible node version.
29 |
30 | ### Installation
31 |
32 | ```bash
33 | $ git clone https://github.com/WordPress/wp-feature-notifications.git
34 | $ cd wp-feature-notifications
35 | $ nvm use && npm i && composer install
36 | $ wp-env start
37 | ```
38 |
39 | We take advantage of [wp-scripts](https://developer.wordpress.org/block-editor/reference-guides/packages/packages-scripts/) to compile scripts and styles for this plugin.
40 | You will mainly need these two commands:
41 |
42 | `npm run build` - Transforms your code according to the configuration provided, so it’s ready for production and optimized for the best performance.
43 | `npm run start`- Transforms your code according to the configuration provided, so it’s ready for development. The script will automatically rebuild if you make changes to the code, and you will see the build errors in the console.
44 |
45 | ## Issue Workflow
46 |
47 | ### Creating an issue
48 |
49 | Have an improvement, suggestion or bug? The first step is to [open an issue](https://github.com/WordPress/wp-feature-notifications/issues). New ideas and new contributors are very welcome! Please be sure to fill out all available fields, and provide as much detail as possible.
50 |
51 | Once your issue has been opened, it will be triaged, labelled and moved to the relevant [project board](https://github.com/WordPress/wp-feature-notifications/projects?type=classic).
52 |
53 | > [!IMPORTANT]
54 | > If your issue is a security vulnerabilty, please practice responsible disclosure and submit this at https://github.com/WordPress/wp-feature-notifications/security/advisories/new.
55 |
56 |
57 | ### Working on an issue
58 |
59 | Please ensure that nobody else is already working on an issue before starting work, in order to avoid duplication of effort. If in doubt, it's best to ask in the issue itself! When starting work, **you should assign the issue to yourself** to make this as clear as possible.
60 |
61 | If you are contributing code, be sure to follow our [Coding Standards](https://developer.wordpress.org/coding-standards/wordpress-coding-standards/) for both JavaScript and PHP.
62 |
63 | You should create one pull request for each indvidual issue you are working on. Make sure to [link it to the issue](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue) for easy tracking. Create a draft pull request as early as possible for visibility; your code doesn't have to be finished to create this!
64 |
65 | Once your work is complete, and all automated checks have passed, please mark your pull request as ready for review. Anyone is free to add a review, however before a pull request can be merged it will need approval from a project maintainer.
66 |
67 | Please tag at least one of [Sephsekla](https://github.com/Sephsekla), [erikyo](https://github.com/erikyo) or [johnhooks](https://github.com/johnhooks)to review.
68 |
69 | ### Merging a pull request
70 |
71 | Once your pull request is approved, it will be merged by a maintainer. Thank you for your contribution to the project!
72 |
73 | ## Releases
74 |
75 | New releases should only be created from the `Trunk` branch. This is handled by a GitHub action whenever a new tag is created on this branch.
76 |
77 | A new release should only be created by a project maintainer after discussion with the team.
78 |
79 | ## Meetings
80 |
81 | We hold weekly office hours at every Wednesday at 15:00 UTC in the [#feature-notifications](https://wordpress.slack.com/messages/C2K1C71FE) channel of the [Make WordPress Slack](https://make.wordpress.org/chat/). New contributors are always welcome!
82 |
83 | We also hold a monthly planning meeting via Google Meet. This is currently held on the last Tuesday of every month at 14:00 UTC.
84 |
--------------------------------------------------------------------------------
/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = ( api ) => {
2 | api.cache( true );
3 |
4 | return {
5 | presets: [ '@wordpress/babel-preset-default' ],
6 | };
7 | };
8 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "wordpress/wp-feature-notifications",
3 | "description": "Notifications for WordPress (Feature Plugin)",
4 | "type": "wordpress-plugin",
5 | "require": {
6 | "ext-json": "*"
7 | },
8 | "require-dev": {
9 | "phpunit/phpunit": "^9.6",
10 | "yoast/phpunit-polyfills": "^1.1",
11 | "dealerdirect/phpcodesniffer-composer-installer": "^1.0",
12 | "wp-coding-standards/wpcs": "^2.3",
13 | "wp-phpunit/wp-phpunit": "^6.2",
14 | "squizlabs/php_codesniffer": "^3.7",
15 | "friendsofphp/php-cs-fixer": "3.16"
16 | },
17 | "license": "GPL-2.0-or-later",
18 | "author": "The WordPress Contributors",
19 | "minimum-stability": "dev",
20 | "prefer-stable": true,
21 | "scripts": {
22 | "lint": "vendor/squizlabs/php_codesniffer/bin/phpcs includes/ -s --report=full,summary,source",
23 | "lint-fix": "vendor/bin/phpcbf --standard=phpcs.xml.dist includes/",
24 | "test": "vendor/bin/phpunit"
25 | },
26 | "config": {
27 | "allow-plugins": {
28 | "dealerdirect/phpcodesniffer-composer-installer": true
29 | },
30 | "platform": {
31 | "php": "7.4"
32 | }
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/includes/channels.php:
--------------------------------------------------------------------------------
1 | register( $name, $args );
27 | }
28 |
29 | /**
30 | * Unregister a channel.
31 | *
32 | * @param string|Model\Channel $name Channel name including namespace, or
33 | * alternatively a complete Channel instance.
34 | * @return Model\Channel|false The unregistered channel on success, or false on failure.
35 | */
36 | function unregister_channel( $name ) {
37 | return Channel_Registry::get_instance()->unregister( $name );
38 | }
39 |
40 | // Register core notification channels.
41 |
42 | add_action(
43 | 'init',
44 | function () {
45 | register_channel(
46 | 'core/updates',
47 | array(
48 | 'title' => __( 'WordPress Updates', 'wp-feature-notifications' ),
49 | 'icon' => 'wordpress',
50 | 'description' => __( 'WordPress core update events.', 'wp-feature-notifications' ),
51 | )
52 | );
53 |
54 | register_channel(
55 | 'core/plugin-install',
56 | array(
57 | 'title' => __( 'Plugin Install', 'wp-feature-notifications' ),
58 | 'icon' => 'wordpress',
59 | 'description' => __( 'Plugin install events.', 'wp-feature-notifications' ),
60 | )
61 | );
62 |
63 | register_channel(
64 | 'core/plugin-uninstall',
65 | array(
66 | 'title' => __( 'Plugin Uninstall', 'wp-feature-notifications' ),
67 | 'icon' => 'wordpress',
68 | 'description' => __( 'Plugin uninstall events.', 'wp-feature-notifications' ),
69 | )
70 | );
71 |
72 | register_channel(
73 | 'core/plugin-activate',
74 | array(
75 | 'title' => __( 'Plugin Activate', 'wp-feature-notifications' ),
76 | 'icon' => 'wordpress',
77 | 'description' => __( 'Plugin activation events.', 'wp-feature-notifications' ),
78 | )
79 | );
80 |
81 | register_channel(
82 | 'core/plugin-deactivate',
83 | array(
84 | 'title' => __( 'Plugin Deactivate', 'wp-feature-notifications' ),
85 | 'icon' => 'wordpress',
86 | 'description' => __( 'Plugin deactivation events.', 'wp-feature-notifications' ),
87 | )
88 | );
89 |
90 | register_channel(
91 | 'core/plugin-updates',
92 | array(
93 | 'title' => __( 'Plugin Update', 'wp-feature-notifications' ),
94 | 'icon' => 'wordpress',
95 | 'description' => __( 'Plugin update events.', 'wp-feature-notifications' ),
96 | )
97 | );
98 |
99 | register_channel(
100 | 'core/post-new',
101 | array(
102 | 'title' => __( 'New Post', 'wp-feature-notifications' ),
103 | 'icon' => 'wordpress',
104 | 'description' => __( 'Post creation events.', 'wp-feature-notifications' ),
105 | )
106 | );
107 |
108 | register_channel(
109 | 'core/post-edit',
110 | array(
111 | 'title' => __( 'Edit Post', 'wp-feature-notifications' ),
112 | 'icon' => 'wordpress',
113 | 'description' => __( 'Post edit events.', 'wp-feature-notifications' ),
114 | )
115 | );
116 |
117 | register_channel(
118 | 'core/post-delete',
119 | array(
120 | 'title' => __( 'Delete Post', 'wp-feature-notifications' ),
121 | 'icon' => 'wordpress',
122 | 'description' => __( 'Post delete events.', 'wp-feature-notifications' ),
123 | )
124 | );
125 |
126 | register_channel(
127 | 'core/comment-new',
128 | array(
129 | 'title' => __( 'New Comment', 'wp-feature-notifications' ),
130 | 'icon' => 'wordpress',
131 | 'description' => __( 'Comment creation events.', 'wp-feature-notifications' ),
132 | )
133 | );
134 | }
135 | );
136 |
--------------------------------------------------------------------------------
/includes/class-activator.php:
--------------------------------------------------------------------------------
1 | WP_FEATURE_NOTIFICATION_PLUGIN_VERSION,
35 | 'max_lifespan' => 1000 * 60 * 60 * 24 * 31 * 6, // 6 months
36 | 'delete_on_dismiss' => false,
37 | );
38 | }
39 |
40 | /**
41 | * Create or Update the WP_Notifications options.
42 | *
43 | * @return void
44 | */
45 | public static function update_options() {
46 |
47 | self::init_options();
48 |
49 | $options = get_option( 'wp_notifications_options' );
50 |
51 | if ( false !== $options ) {
52 |
53 | // Update the plugin options but add the new options automatically
54 | if ( isset( $options['version'] ) ) {
55 | unset( $options['version'] );
56 | }
57 |
58 | // Merge previous options, preserve the previously modified options as default.
59 | $new_options = array_merge( self::$default_options, $options );
60 |
61 | update_option( 'wp_notifications_options', $new_options );
62 | } else {
63 | // If the plugin options are missing, initialize the plugin with the default options.
64 | $new_options = array_merge( self::$default_options );
65 |
66 | add_option( 'wp_notifications_options', $new_options );
67 | }
68 | }
69 |
70 | /**
71 | * Install the WP Notifications plugin.
72 | *
73 | * Create the plugin's database tables and options
74 | *
75 | * @return void
76 | */
77 | public static function install() {
78 | global $wpdb;
79 |
80 | // Engage multisite if in the middle of turning it on from network.php.
81 | $is_multisite = is_multisite() || ( defined( 'WP_INSTALLING_NETWORK' ) && WP_INSTALLING_NETWORK );
82 |
83 | if ( $is_multisite ) {
84 | // Get all blogs in the network and uninstall the plugin on each one.
85 | $blog_ids = $wpdb->get_col( "SELECT blog_id FROM $wpdb->blogs" );
86 |
87 | // Loop over the individual sites and create tables for each.
88 | foreach ( $blog_ids as $blog_id ) {
89 | switch_to_blog( $blog_id );
90 |
91 | self::create_tables();
92 |
93 | restore_current_blog();
94 | }
95 | }
96 |
97 | // Always create the main site database tables and options.
98 | self::create_tables();
99 | }
100 |
101 | /**
102 | * Activate the WP Notifications plugin.
103 | *
104 | * @return void
105 | */
106 | public static function activate() {
107 | self::install();
108 | set_transient( 'wp_notifications_activation', true );
109 | }
110 |
111 | /**
112 | * Create the WP Notifications tables and options.
113 | *
114 | * @return void
115 | */
116 | public static function create_tables() {
117 | $db_version = get_option( 'wp_notifications_db_version' );
118 |
119 | if ( ! $db_version ) {
120 | self::create_tables_v1();
121 | update_option( 'wp_notifications_db_version', WP_FEATURE_NOTIFICATION_DB_VERSION );
122 | }
123 |
124 | // If the options do not exist then create them
125 | self::update_options();
126 | }
127 |
128 | /**
129 | * Create v1 WP Notifications tables and options.
130 | *
131 | * @return void
132 | */
133 | public static function create_tables_v1() {
134 | global $wpdb;
135 |
136 | require_once ABSPATH . 'wp-admin/includes/upgrade.php';
137 | require_once WP_FEATURE_NOTIFICATION_PLUGIN_DIR . '/includes/database/class-schema.php';
138 |
139 | $tables = $wpdb->get_results( 'SHOW TABLES' );
140 |
141 | // Create the messages table
142 | if ( ! in_array( $wpdb->prefix . 'notifications_messages', $tables, true ) ) {
143 | $messages_sql = Database\Schema::messages_table_v1();
144 | dbDelta( $messages_sql );
145 | }
146 |
147 | // Create the subscriptions table
148 | if ( ! in_array( $wpdb->prefix . 'notifications_subscriptions', $tables, true ) ) {
149 | $subscriptions_sql = Database\Schema::subscriptions_table_v1();
150 | dbDelta( $subscriptions_sql );
151 | }
152 |
153 | // Create the queue table
154 | if ( ! in_array( $wpdb->prefix . 'notifications_queue', $tables, true ) ) {
155 | $queue_sql = Database\Schema::queue_table_v1();
156 | dbDelta( $queue_sql );
157 | }
158 | }
159 | }
160 |
--------------------------------------------------------------------------------
/includes/class-uninstaller.php:
--------------------------------------------------------------------------------
1 | get_col( "SELECT blog_id FROM $wpdb->blogs" );
49 |
50 | foreach ( $blog_ids as $blog_id ) {
51 | switch_to_blog( $blog_id );
52 |
53 | self::drop_tables();
54 | self::delete_options();
55 |
56 | restore_current_blog();
57 | }
58 | }
59 |
60 | // Always remove the main site database tables and options.
61 | self::drop_tables();
62 | self::delete_options();
63 | }
64 |
65 | /**
66 | * Drop the WP Notifications database tables.
67 | *
68 | * @return void
69 | */
70 | public static function drop_tables() {
71 | global $wpdb;
72 |
73 | $wpdb->query( 'DROP TABLE IF EXISTS ' . $wpdb->prefix . 'notifications_messages' );
74 | $wpdb->query( 'DROP TABLE IF EXISTS ' . $wpdb->prefix . 'notifications_subscriptions' );
75 | $wpdb->query( 'DROP TABLE IF EXISTS ' . $wpdb->prefix . 'notifications_queue' );
76 | }
77 |
78 | /**
79 | * Delete the WP Notifications options.
80 | *
81 | * @return void
82 | */
83 | public static function delete_options() {
84 | delete_option( 'wp_notifications_db_version' );
85 | delete_option( 'wp_notifications_options' );
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/includes/database/class-schema.php:
--------------------------------------------------------------------------------
1 | get_charset_collate();
21 |
22 | return 'CREATE TABLE `' . $wpdb->prefix . "notifications_messages` (
23 | `id` BIGINT(20) NOT NULL,
24 | `channel_name` VARCHAR(50) NOT NULL,
25 | `channel_title` TINYTEXT NOT NULL,
26 | `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP(),
27 | `expires_at` DATETIME NULL,
28 | `severity` VARCHAR(16) NULL,
29 | `title` TINYTEXT NULL,
30 | `message` TINYTEXT NULL,
31 | `meta` TEXT NULL,
32 | PRIMARY KEY (`id`),
33 | KEY `channel_name` (`channel_name`)
34 | ) $charset_collate;\n";
35 | }
36 |
37 |
38 | public static function subscriptions_table_v1() {
39 | global $wpdb;
40 |
41 | $charset_collate = $wpdb->get_charset_collate();
42 |
43 | return 'CREATE TABLE `' . $wpdb->prefix . "notifications_subscriptions` (
44 | `user_id` BIGINT(20) NOT NULL,
45 | `channel_name` VARCHAR(50) NOT NULL,
46 | `snoozed_until` DATETIME NULL,
47 | KEY `user_id` (`user_id`),
48 | KEY `channel_name` (`channel_name`)
49 | ) $charset_collate;\n";
50 | }
51 |
52 | public static function queue_table_v1() {
53 | global $wpdb;
54 |
55 | $charset_collate = $wpdb->get_charset_collate();
56 |
57 | return 'CREATE TABLE `' . $wpdb->prefix . "notifications_queue` (
58 | `message_id` BIGINT(20) NOT NULL,
59 | `user_id` BIGINT(20) NOT NULL,
60 | `dismissed_at` DATETIME NULL,
61 | `displayed_at` DATETIME NULL,
62 | KEY `message_id` (`message_id`),
63 | KEY `user_id` (`user_id`)
64 | ) $charset_collate;\n";
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/includes/exceptions/class-runtime-exception.php:
--------------------------------------------------------------------------------
1 |
18 | */
19 | class Message extends Framework\Factory {
20 |
21 | /**
22 | * Instantiates a Message object.
23 | *
24 | * @param array|string $args {
25 | * Array or string of arguments for creating a message. Supported arguments
26 | * are described below.
27 | *
28 | * @type ?string $message Text content of the message.
29 | * @type ?string $accept_label Optional label of the accept action.
30 | * @type ?string $accept_link Optional url of the accept action.
31 | * @type ?string $accept_message Optional label of the accept action.
32 | * @type ?string $channel_title Optional human-readable title of the channel
33 | * the message was emitted from.
34 | * @type ?DateTime $created_at Optional datetime at which a message was created.
35 | * Default `'null'`
36 | * @type ?string $dismiss_label Optional label of the dismiss action.
37 | * @type ?DateTime $expires_at Optional datetime at which a message expires.
38 | * Default `'null'`
39 | * @type ?string $icon Optional icon of the message. Default `null`
40 | * @type ?int $id Optional database ID of the message. Default `null`
41 | * @type ?bool $is_dismissible Optional boolean of whether the notice can
42 | * be dismissed. Default `true`
43 | * @type ?string $severity Optional severity of the message. Default `null`
44 | * @type string $title Optional human-readable message label. Default `''`
45 | * }
46 | *
47 | * @return Model\Message A newly created instance of Message or false.
48 | */
49 | public function make( $args = array() ): Model\Message {
50 | $parsed = wp_parse_args( $args );
51 |
52 | // Required properties
53 |
54 | $message = array_key_exists( 'message', $parsed ) ? $parsed['message'] : null;
55 |
56 | // Optional properties
57 |
58 | $accept_label = array_key_exists( 'accept_label', $parsed ) ? $parsed['accept_label'] : null;
59 | $accept_link = array_key_exists( 'accept_link', $parsed ) ? $parsed['accept_link'] : null;
60 | $channel_title = array_key_exists( 'channel_title', $parsed ) ? $parsed['channel_title'] : null;
61 | $created_at = array_key_exists( 'created_at', $parsed ) ? $parsed['created_at'] : null;
62 | $dismiss_label = array_key_exists( 'dismiss_label', $parsed ) ? $parsed['dismiss_label'] : null;
63 | $expires_at = array_key_exists( 'expires_at', $parsed ) ? $parsed['expires_at'] : null;
64 | $icon = array_key_exists( 'icon', $parsed ) ? $parsed['icon'] : null;
65 | $id = array_key_exists( 'id', $parsed ) ? $parsed['id'] : null;
66 | $is_dismissible = array_key_exists( 'is_dismissible', $parsed ) ? $parsed['is_dismissible'] : true;
67 | $severity = array_key_exists( 'severity', $parsed ) ? $parsed['severity'] : null;
68 | $title = array_key_exists( 'title', $parsed ) ? $parsed['title'] : '';
69 |
70 | return new Model\Message(
71 | $message,
72 | $accept_label,
73 | $accept_link,
74 | $channel_title,
75 | $created_at,
76 | $dismiss_label,
77 | $expires_at,
78 | $icon,
79 | $id,
80 | $is_dismissible,
81 | $severity,
82 | $title,
83 | );
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/includes/factory/class-notification.php:
--------------------------------------------------------------------------------
1 |
19 | */
20 | class Notification extends Framework\Factory {
21 |
22 | /**
23 | * Instantiates a Notification object.
24 |
25 | * @param array|string $args {
26 | * Array or string of arguments for creating a notification. Supported arguments are described below.
27 | *
28 | * @type ?string $channel_name Channel name, including namespace,
29 | * the notification was emitted from.
30 | * @type ?int $message_id ID of the message related to the
31 | * notification.
32 | * @type ?int $user_id ID of the user the notification
33 | * belongs to.
34 | * @type ?string $context Optional display context of the
35 | * notification. Default `'adminbar'`
36 | * @type string|DateTime|null $created_at Optional datetime at which the
37 | * notification was created. Default `null`
38 | * @type string|DateTime|null $dismissed_at Optional datetime t which the
39 | * notification was dismissed. Default `null`
40 | * @type string|DateTime|null $displayed_at Optional datetime at which the
41 | * notification was first displayed.
42 | * Default `null`
43 | * @type string|DateTime|null $expires_at Optional datetime at which the
44 | * notification expires. Default `null`
45 | * }
46 | *
47 | * @return Model\Notification A newly created instance of Channel or false.
48 | */
49 | public function make( $args = array() ): Model\Notification {
50 | $parsed = wp_parse_args( $args );
51 |
52 | // Required properties
53 |
54 | $channel_name = array_key_exists( 'channel_name', $parsed ) ? $parsed['channel_name'] : null;
55 | $message_id = array_key_exists( 'message_id', $parsed ) ? $parsed['message_id'] : null;
56 | $user_id = array_key_exists( 'user_id', $parsed ) ? $parsed['user_id'] : null;
57 |
58 | // Optional properties
59 |
60 | $context = array_key_exists( 'context', $parsed ) ? $parsed['context'] : 'adminbar';
61 | $created_at = array_key_exists( 'created_at', $parsed ) ? $parsed['created_at'] : null;
62 | $dismissed_at = array_key_exists( 'dismissed_at', $parsed ) ? $parsed['dismissed_at'] : null;
63 | $displayed_at = array_key_exists( 'displayed_at', $parsed ) ? $parsed['displayed_at'] : null;
64 | $expires_at = array_key_exists( 'expires_at', $parsed ) ? $parsed['expires_at'] : null;
65 |
66 | // Deserialize MySQL datetime strings.
67 |
68 | $created_at = Helper\Serde::maybe_deserialize_mysql_date( $created_at );
69 | $dismissed_at = Helper\Serde::maybe_deserialize_mysql_date( $dismissed_at );
70 | $displayed_at = Helper\Serde::maybe_deserialize_mysql_date( $displayed_at );
71 | $expires_at = Helper\Serde::maybe_deserialize_mysql_date( $expires_at );
72 |
73 | return new Model\Notification(
74 | $channel_name,
75 | $message_id,
76 | $user_id,
77 | $context,
78 | $created_at,
79 | $dismissed_at,
80 | $displayed_at,
81 | $expires_at
82 | );
83 | }
84 | }
85 |
--------------------------------------------------------------------------------
/includes/factory/class-subscription.php:
--------------------------------------------------------------------------------
1 | .
19 | */
20 | class Subscription extends Framework\Factory {
21 |
22 | /**
23 | * Instantiates a Subscription object.
24 | *
25 | * @param array|string $args {
26 | * Array or string of arguments for creating a subscription. Supported
27 | * arguments are described below.
28 | *
29 | * @type ?string $channel_name Namespaced channel name of the
30 | * subscription.
31 | * @type ?int $user_id ID of the user the subscription
32 | * belongs to.
33 | * @type string|DateTime|null $created_at Optional datetime at which the
34 | * subscription was created.
35 | * @type string|DateTime|null $snoozed_until Optional snoozed until datetime
36 | * of the subscription.
37 | * }
38 | *
39 | * @return Model\Subscription A newly created instance of Subscription or false.
40 | */
41 | public function make( $args = array() ): Model\Subscription {
42 | $parsed = wp_parse_args( $args );
43 |
44 | // Required properties
45 |
46 | $channel_name = array_key_exists( 'channel_name', $parsed ) ? $parsed['channel_name'] : null;
47 | $user_id = array_key_exists( 'user_id', $parsed ) ? $parsed['user_id'] : null;
48 |
49 | // Optional properties
50 |
51 | $created_at = array_key_exists( 'created_at', $parsed ) ? $parsed['created_at'] : null;
52 | $snoozed_until = array_key_exists( 'snoozed_until', $parsed ) ? $parsed['snoozed_until'] : null;
53 |
54 | // Deserialize MySQL datetime strings.
55 |
56 | $created_at = Helper\Serde::maybe_deserialize_mysql_date( $created_at );
57 | $snoozed_until = Helper\Serde::maybe_deserialize_mysql_date( $snoozed_until );
58 |
59 | return new Model\Subscription(
60 | $channel_name,
61 | $user_id,
62 | $created_at,
63 | $snoozed_until,
64 | );
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/includes/framework/class-factory.php:
--------------------------------------------------------------------------------
1 | format( DateTime::ATOM );
32 | }
33 |
34 | /**
35 | * Maybe deserialize a datetime string in MySQL format.
36 | *
37 | * @param string|DateTime|null $date The possible MySQL datetime to deserialize.
38 | *
39 | * @return DateTime|null Maybe a DateTime object.
40 | */
41 | public static function maybe_deserialize_mysql_date( $date ) {
42 | if ( null === $date ) {
43 | return null;
44 | }
45 |
46 | if ( $date instanceof DateTime ) {
47 | return $date;
48 | }
49 |
50 | if ( is_string( $date ) ) {
51 | $date = DateTime::createFromFormat( 'Y-m-d H:i:s', $date );
52 |
53 | if ( false === $date ) {
54 | $date = null;
55 | }
56 | }
57 |
58 | return $date;
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/includes/image/class-base-image.php:
--------------------------------------------------------------------------------
1 | html node
11 | */
12 | protected $source;
13 |
14 | /**
15 | * @var string for alternative text of an
html node
16 | */
17 | protected $alt = '';
18 |
19 | /**
20 | * BaseImage constructor.
21 | *
22 | * @param string $source
23 | * @param string $alt
24 | */
25 | public function __construct( $source, $alt = '' ) {
26 | $this->source = $source;
27 | $this->alt = $alt;
28 | }
29 |
30 | /**
31 | * @return array
32 | */
33 | public function jsonSerialize() {
34 |
35 | $data = array();
36 |
37 | if ( ! empty( $this->get_source() ) ) {
38 | $data['source'] = $this->get_source();
39 | }
40 |
41 | if ( ! empty( $this->get_alt() ) ) {
42 | $data['alt'] = $this->get_alt();
43 | }
44 |
45 | return $data;
46 | }
47 |
48 | /**
49 | * Source of the image to be used on src attribute of
50 | *
51 | * @return string
52 | */
53 | public function get_source() {
54 | return $this->source;
55 | }
56 |
57 | /**
58 | * Alternative text to be used on alt attribute of
59 | *
60 | * @return string
61 | */
62 | public function get_alt() {
63 | return $this->alt;
64 | }
65 |
66 | /**
67 | * Instantiates a BaseImage based on a JSON string
68 | *
69 | * @param string $json
70 | *
71 | * @return Json_Unserializable
72 | */
73 | public static function json_unserialize( $json ) {
74 |
75 | $data = json_decode( $json, true );
76 |
77 | $source = ! empty( $data['source'] ) ? $data['source'] : '';
78 | $alt = ! empty( $data['alt'] ) ? $data['alt'] : '';
79 |
80 | return new self( $source, $alt );
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/includes/image/interface-image.php:
--------------------------------------------------------------------------------
1 | register_routes();
36 | $notification_controller->register_routes();
37 | $subscription_controller->register_routes();
38 |
39 | }
40 |
41 | add_action( 'rest_api_init', '\WP\Notifications\register_routes' );
42 |
43 | /**
44 | * Adds WP Notifications icon after the user avatar in the top admin bar in the "secondary" position
45 | *
46 | * @param WP_Admin_Bar $wp_admin_bar Toolbar instance.
47 | */
48 | function admin_bar_item( WP_Admin_Bar $wp_admin_bar ) {
49 | if ( ! is_user_logged_in() ) {
50 | return;
51 | }
52 |
53 | /**
54 | * This is the same HTML as the `src/scripts/components/NotificationHub.js`
55 | * If this is changed that file must also be updated.
56 | */
57 | $notification_hub = sprintf(
58 | '',
59 | __( 'Notifications', 'wp-feature-notifications' )
60 | );
61 |
62 | /**
63 | * This is the same HTML as the `src/scripts/components/NotificationHubIcon.js`
64 | * If this is changed that file must also be updated.
65 | */
66 | $notification_hub_icon = sprintf(
67 | '
',
68 | __( 'Notifications', 'wp-feature-notifications' )
69 | );
70 |
71 | $args = array(
72 | 'id' => 'wp-notifications-hub',
73 | 'parent' => 'top-secondary',
74 | 'title' => $notification_hub_icon,
75 | 'meta' => array(
76 | 'tabindex' => 0,
77 | 'html' => $notification_hub,
78 | ),
79 | );
80 | $wp_admin_bar->add_node( $args );
81 | }
82 |
83 | add_action( 'admin_bar_menu', '\WP\Notifications\admin_bar_item', 1 );
84 |
85 | /**
86 | * Register and enqueue a wp-notifications scripts and stylesheet in WordPress admin.
87 | */
88 | function enqueue_admin_assets() {
89 | if ( ! is_user_logged_in() ) {
90 | return;
91 | }
92 |
93 | /* Load styles */
94 | wp_register_style( 'wp_notifications', WP_FEATURE_NOTIFICATION_PLUGIN_DIR_URL . '/build/wp-notifications.css', array(), WP_FEATURE_NOTIFICATION_PLUGIN_VERSION );
95 | wp_enqueue_style( 'wp_notifications' );
96 |
97 | /* Load scripts */
98 | $asset = include WP_FEATURE_NOTIFICATION_PLUGIN_DIR . '/build/wp-notifications.asset.php';
99 | wp_register_script( 'wp_notifications', WP_FEATURE_NOTIFICATION_PLUGIN_DIR_URL . '/build/wp-notifications.js', $asset['dependencies'], WP_FEATURE_NOTIFICATION_PLUGIN_VERSION, true );
100 | wp_enqueue_script( 'wp_notifications' );
101 | }
102 |
103 |
104 | add_action( 'wp_enqueue_scripts', '\WP\Notifications\enqueue_admin_assets', 0 );
105 | add_action( 'admin_enqueue_scripts', '\WP\Notifications\enqueue_admin_assets', 0 );
106 |
--------------------------------------------------------------------------------
/includes/model/class-channel.php:
--------------------------------------------------------------------------------
1 | name = $name;
72 | $this->title = $title;
73 |
74 | // Optional properties
75 |
76 | $this->context = $context;
77 | $this->icon = $icon;
78 | $this->description = $description;
79 | }
80 |
81 | /**
82 | * Specifies data which should be serialized to JSON.
83 | *
84 | * @return array Data which can be serialized by json_encode, which is a
85 | * value of any type other than a resource.
86 | */
87 | public function jsonSerialize() {
88 | return array(
89 | 'context' => $this->context,
90 | 'description' => $this->description,
91 | 'icon' => $this->icon,
92 | 'name' => $this->name,
93 | 'title' => $this->title,
94 | );
95 | }
96 |
97 | /**
98 | * Get the namespaced name.
99 | *
100 | * @return ?string The namespaced name of the channel.
101 | */
102 | public function get_name(): ?string {
103 | return $this->name;
104 | }
105 |
106 | /**
107 | * Get the human-readable label.
108 | *
109 | * @return ?string The title of the channel.
110 | */
111 | public function get_title(): ?string {
112 | return $this->title;
113 | }
114 |
115 | /**
116 | * Get the default display context.
117 | *
118 | * @return ?string The context of the channel.
119 | */
120 | public function get_context(): ?string {
121 | return $this->context;
122 | }
123 |
124 | /**
125 | * Get the detailed description.
126 | *
127 | * @return ?string The description of the channel.
128 | */
129 | public function get_description(): ?string {
130 | return $this->description;
131 | }
132 |
133 | /**
134 | * Get the icon.
135 | *
136 | * @return ?string The icon of the channel.
137 | */
138 | public function get_icon(): ?string {
139 | return $this->icon;
140 | }
141 | }
142 |
--------------------------------------------------------------------------------
/includes/model/class-subscription.php:
--------------------------------------------------------------------------------
1 | channel_name = $channel_name;
68 | $this->user_id = $user_id;
69 |
70 | // Optional properties
71 |
72 | $this->created_at = $created_at;
73 | $this->snoozed_until = $snoozed_until;
74 | }
75 |
76 | /**
77 | * Specifies data which should be serialized to JSON.
78 | *
79 | * @return array Data which can be serialized by json_encode, which is a
80 | * value of any type other than a resource.
81 | */
82 | public function jsonSerialize() {
83 | return array(
84 | 'channel_name' => $this->channel_name,
85 | 'created_at' => Helper\Serde::maybe_serialize_json_date( $this->created_at ),
86 | 'snoozed_until' => Helper\Serde::maybe_serialize_json_date( $this->snoozed_until ),
87 | 'user_id' => $this->user_id,
88 | );
89 | }
90 |
91 | /**
92 | * Get the namespaced channel name.
93 | *
94 | * @return ?string The namespaced channel name of the subscription.
95 | */
96 | public function get_channel_name(): ?string {
97 | return $this->channel_name;
98 | }
99 |
100 | /**
101 | * Get the created at datetime.
102 | *
103 | * @return ?DateTime The datetime at which the subscription was created.
104 | */
105 | public function get_created_at(): ?DateTime {
106 | return $this->created_at;
107 | }
108 |
109 | /**
110 | * Get the snoozed until option.
111 | *
112 | * @return ?DateTime The snoozed until option of the subscription.
113 | */
114 | public function get_snoozed_until(): ?DateTime {
115 | return $this->snoozed_until;
116 | }
117 |
118 | /**
119 | * Get the user ID.
120 | *
121 | * @return ?int The user ID of the subscription.
122 | */
123 | public function get_user_id(): ?int {
124 | return $this->user_id;
125 | }
126 |
127 | }
128 |
--------------------------------------------------------------------------------
/includes/persistence/class-abstract-notification-repository.php:
--------------------------------------------------------------------------------
1 | sub( $interval );
32 |
33 | return $this->find_by_date_range(
34 | $start,
35 | $end,
36 | Order::DESCENDING,
37 | $pagination,
38 | $offset
39 | );
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/includes/persistence/class-wpdb-notification-repository.php:
--------------------------------------------------------------------------------
1 | WP_REST_Server::READABLE,
49 | 'args' => $this->get_collection_params(),
50 | 'callback' => array( $this, 'get_items' ),
51 | 'permission_callback' => array( $this, 'get_items_permissions_check' ),
52 | ),
53 | 'schema' => array( $this, 'get_public_item_schema' ),
54 | ),
55 | false
56 | );
57 | }
58 |
59 | /**
60 | * Checks if a given request has access to view channels.
61 | *
62 | * @param WP_REST_Request $request Full details about the request.
63 | *
64 | * @return true|WP_Error True if the request has access to view the items, error object otherwise.
65 | */
66 | public function get_items_permissions_check( $request ) {
67 | if ( ! is_user_logged_in() ) {
68 | return new WP_Error(
69 | 'rest_notifications_login_required',
70 | __( 'Sorry, you must be logged to view channels.' ),
71 | array( 'status' => 401 )
72 | );
73 | }
74 | return true;
75 | }
76 |
77 | /**
78 | * Get channels for request.
79 | *
80 | * @param WP_REST_Request $request Received REST request
81 | *
82 | * @return WP_REST_RESPONSE|WP_Error REST response or WP Error
83 | */
84 | public function get_items( $request ) {
85 | $channels = Channel_Registry::get_instance()->get_all_registered();
86 |
87 | // TODO filter based on permissions.
88 |
89 | return rest_ensure_response( $channels );
90 | }
91 |
92 | /**
93 | * Retrieves the channels' schema, conforming to JSON Schema.
94 | *
95 | * @return array The notification channel schema.
96 | */
97 | public function get_item_schema(): array {
98 | if ( $this->schema ) {
99 | return $this->add_additional_fields_schema( $this->schema );
100 | }
101 |
102 | $schema = array(
103 | '$schema' => 'http://json-schema.org/draft-04/schema#',
104 | 'title' => 'channel',
105 | 'type' => 'object',
106 | 'properties' => array(
107 | 'context' => array(
108 | 'description' => __( 'The default view context the notification channel.' ),
109 | 'type' => 'string',
110 | 'context' => array( 'view', 'embed' ),
111 | 'enum' => array(
112 | 'adminbar',
113 | 'dashboard',
114 | ),
115 | 'readonly' => true,
116 | ),
117 | 'icon' => array(
118 | 'description' => __( 'The default icon of the notification channel.' ),
119 | 'type' => 'integer',
120 | 'context' => array( 'view', 'embed' ),
121 | 'readonly' => true,
122 | ),
123 | 'name' => array(
124 | 'description' => __( 'Unique identifier for the notification channel.' ),
125 | 'type' => 'string',
126 | 'context' => array( 'view', 'embed' ),
127 | 'readonly' => true,
128 | ),
129 | 'title' => array(
130 | 'description' => __( 'The human-readable label of the notification channel.' ),
131 | 'type' => 'string',
132 | 'context' => array( 'view', 'embed' ),
133 | 'readonly' => true,
134 | ),
135 | ),
136 | );
137 |
138 | // Cache generated schema on endpoint instance.
139 | $this->schema = $schema;
140 |
141 | return $this->add_additional_fields_schema( $this->schema );
142 | }
143 |
144 | /**
145 | * Retrieves the query params for collections.
146 | *
147 | * @return array Channel collection parameters.
148 | */
149 | public function get_collection_params(): array {
150 | $query_params = parent::get_collection_params();
151 |
152 | $query_params['context']['default'] = 'view';
153 |
154 | $query_params['offset'] = array(
155 | 'description' => __( 'Offset the result set by a specific number of items.' ),
156 | 'type' => 'integer',
157 | );
158 |
159 | $query_params['context'] = array(
160 | 'description' => __( 'Limit result set to channels assigned a specific display context.' ),
161 | 'default' => 'all',
162 | 'type' => 'string',
163 | );
164 |
165 | return $query_params;
166 | }
167 | }
168 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | const jestConfig = {
2 | verbose: true,
3 | preset: '@wordpress/jest-preset-default',
4 | setupFilesAfterEnv: [ 'expect-puppeteer' ],
5 | projects: [
6 | {
7 | displayName: 'unit',
8 | testMatch: [ '/tests/jsunit/**/*.test.js' ],
9 | },
10 | {
11 | displayName: 'e2e',
12 | preset: 'jest-puppeteer',
13 | testMatch: [ '/tests/jse2e/**/*.test.js' ],
14 | },
15 | ],
16 | };
17 |
18 | module.exports = jestConfig;
19 |
--------------------------------------------------------------------------------
/languages/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/WordPress/wp-feature-notifications/ac990811d8c8ba4ba090fe99f71839991866b662/languages/.gitkeep
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "wp-feature-notifications",
3 | "version": "0.1.0",
4 | "description": "A notification center for WordPress.",
5 | "keywords": [
6 | "WordPress",
7 | "notifications",
8 | "dashboard"
9 | ],
10 | "author": "The WordPress Contributors",
11 | "license": "GPL-2.0-or-later",
12 | "bugs": {
13 | "url": "https://github.com/WordPress/wp-feature-notifications/issues"
14 | },
15 | "homepage": "https://github.com/WordPress/wp-feature-notifications#readme",
16 | "directories": {
17 | "doc": "docs",
18 | "test": "tests"
19 | },
20 | "files": [
21 | "build/*",
22 | "includes/*",
23 | "readme.txt",
24 | "wp-feature-notifications.php"
25 | ],
26 | "workspaces": [
27 | "./storybook"
28 | ],
29 | "scripts": {
30 | "start": "wp-scripts start",
31 | "start:storybook": "npm run start --workspace=storybook",
32 | "build": "wp-scripts build",
33 | "build:storybook": "npm run build --workspace=storybook",
34 | "php-install": "wp-env run composer 'composer --ignore-platform-req=php install'",
35 | "packages-update": "wp-scripts packages-update",
36 | "check-engines": "wp-scripts check-engines",
37 | "check-licenses": "wp-scripts check-licenses",
38 | "format": "wp-scripts format",
39 | "lint:css": "wp-scripts lint-style ./src",
40 | "lint:js": "wp-scripts lint-js ./src",
41 | "lint:md:docs": "wp-scripts lint-md-docs ./src",
42 | "lint:pkg-json": "wp-scripts lint-pkg-json ./src",
43 | "plugin-zip": "wp-scripts plugin-zip",
44 | "test:js": "jest",
45 | "test:php": "wp-env run phpunit 'phpunit --configuration=/var/www/html/wp-content/plugins/wp-feature-notifications/phpunit.xml.dist'",
46 | "test": "npm run test:php && npm run test:js",
47 | "docGen": "npx docgen src/scripts/wp-notifications.js",
48 | "prepare": "husky install"
49 | },
50 | "repository": {
51 | "type": "git",
52 | "url": "git+https://github.com/WordPress/wp-feature-notifications.git"
53 | },
54 | "engines": {
55 | "node": ">=14",
56 | "npm": ">=7"
57 | },
58 | "devDependencies": {
59 | "@types/jest": "^29.5.10",
60 | "@wordpress/api-fetch": "^6.44.0",
61 | "@wordpress/components": "^25.13.0",
62 | "@wordpress/data": "^9.17.0",
63 | "@wordpress/date": "^4.47.0",
64 | "@wordpress/e2e-test-utils": "^10.18.0",
65 | "@wordpress/env": "^8.13.0",
66 | "@wordpress/escape-html": "^2.47.0",
67 | "@wordpress/eslint-plugin": "^17.4.0",
68 | "@wordpress/i18n": "^4.47.0",
69 | "@wordpress/icons": "^9.38.0",
70 | "@wordpress/keyboard-shortcuts": "^4.24.0",
71 | "@wordpress/scripts": "^26.18.0",
72 | "classnames": "^2.3.2",
73 | "eslint-import-resolver-typescript": "^3.6.1",
74 | "husky": "^8.0.3",
75 | "jest-puppeteer": "^9.0.1",
76 | "lint-staged": "^15.1.0",
77 | "prettier": "npm:wp-prettier@^3.0.3",
78 | "re-resizable": "^6.9.11",
79 | "typescript": "^5.3.2"
80 | },
81 | "browserslist": [
82 | "extends @wordpress/browserslist-config"
83 | ]
84 | }
85 |
--------------------------------------------------------------------------------
/phpcs.xml.dist:
--------------------------------------------------------------------------------
1 |
2 |
3 | Apply WordPress Coding Standards to plugin and tests files
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 | .
27 |
28 |
29 |
30 |
31 |
32 |
33 | warning
34 |
35 |
36 | warning
37 |
38 |
39 | warning
40 |
41 |
42 | warning
43 |
44 |
45 |
46 |
47 |
48 | /node_modules/*
49 | /vendor/*
50 | /build/*
51 |
52 |
53 |
54 | /tests/phpunit/tests/*
55 |
56 |
57 | /tests/phpunit/tests/*
58 |
59 |
60 | /tests/phpunit/tests/*
61 |
62 |
63 |
--------------------------------------------------------------------------------
/phpunit.xml.dist:
--------------------------------------------------------------------------------
1 |
7 |
8 |
9 |
10 | tests/phpunit/tests
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/readme.txt:
--------------------------------------------------------------------------------
1 | === WP Feature Notifications ===
2 | Contributors: schlessera, psykro, raaaahman, danbilauca, sephsekla, bacoords, erikyo
3 | Tags: feature-notifications
4 | Requires at least: 6.2
5 | Tested up to: 6.2
6 | Requires PHP: 7.4
7 | License: GPLv2 or later
8 | License URI: https://www.gnu.org/licenses/gpl-2.0.html
9 |
10 | A new (better) way to manage and deliver WordPress notifications to the relevant audience.
11 |
12 | == Description ==
13 |
14 | 1. Allow WordPress core to send notifications to administrative users to give them feedback about changes in the system.
15 | 1. Allow plugin and theme authors to send notifications to administrative users to give them feedback about changes in the system
16 | 1. Prevent plugin and theme authors from abusing this notification system in “spammy” ways.
17 | 1. Allow WordPress users who have access to notifications to control which, how, and where they receive them.
18 |
19 | Want to contribute? Checkout the code [on GitHub](https://github.com/WordPress/wp-feature-notifications) and read more about the project's goals on our [wiki](https://github.com/WordPress/wp-feature-notifications/wiki).
20 |
21 | == Screenshots ==
22 |
23 | == Changelog ==
24 |
25 | = 0.0.1 =
26 | * Initial Release
27 |
28 |
29 |
30 |
--------------------------------------------------------------------------------
/src/components/drawer/index.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from '@wordpress/element';
2 | import { __ } from '@wordpress/i18n';
3 | import { useShortcut } from '@wordpress/keyboard-shortcuts';
4 | import { Resizable } from 're-resizable';
5 | import type { FocusEventHandler, MutableRefObject } from 'react';
6 |
7 | import { HUB_WIDTH } from '../../constants';
8 | import NoticesArea from '../notice-area';
9 |
10 | /**
11 | * The `Drawer` props type.
12 | */
13 | type Props = {
14 | blur: FocusEventHandler< HTMLElement >;
15 | focus: FocusEventHandler< HTMLElement >;
16 | drawRef: MutableRefObject< HTMLElement >;
17 | };
18 |
19 | /**
20 | * A resizable drawer React component for displaying notifications. The returned
21 | * `Drawer` component is an `aside` element containing a `Resizable` component and
22 | * `NoticesArea` component. The `Resizable` component provides UI controls to resize
23 | * the width of the `Drawer` by dragging its left edge. The `NoticesArea` component
24 | * displays notifications in the `Drawer`.
25 | *
26 | * @param props
27 | * @param props.focus The `onFocus` event listener for the drawer.
28 | * @param props.blur The `onBlur` event listener for the drawer.
29 | * @param props.drawRef The reference to the drawer element.
30 | */
31 | export default function Drawer( { focus, blur, drawRef }: Props ) {
32 | /**
33 | * Enables the shortcut to close the drawer with the escape key
34 | */
35 | useShortcut( 'wp-feature-notifications/close-drawer', () => blur );
36 | const [ width, setWidth ] = useState( HUB_WIDTH );
37 |
38 | return (
39 |
62 | );
63 | }
64 |
--------------------------------------------------------------------------------
/src/components/hub-icon/index.tsx:
--------------------------------------------------------------------------------
1 | import { __ } from '@wordpress/i18n';
2 | import { useShortcut } from '@wordpress/keyboard-shortcuts';
3 | import classNames from 'classnames';
4 |
5 | import UnreadDot from '../unread-dot';
6 |
7 | /**
8 | * The HTML rendered by this component is the same as the variable `notification_hub_icon`
9 | * in`includes/load.php`. If the output of this component is modified that file must
10 | * also be updated.
11 | */
12 |
13 | /**
14 | * The `HubIcon` props type.
15 | */
16 | type Props = {
17 | classes?: string[];
18 | hasUnread?: boolean;
19 | isActive: boolean;
20 | toggle: Function;
21 | };
22 |
23 | /**
24 | * Notification icon UI component.
25 | *
26 | * @param props
27 | * @param props.toggle Toggle the drawer on and off.
28 | * @param props.isActive Predicate of whether the drawer is in an active state.
29 | * @param props.hasUnread Predicate of whether there are unread notifications.
30 | * @param props.classes The icon class names (defaults to [ 'dashicons-bell' ])
31 | */
32 | export default function HubIcon( {
33 | toggle,
34 | isActive,
35 | hasUnread = false,
36 | classes = [ 'dashicons-bell' ],
37 | }: Props ) {
38 | /**
39 | * Enables the shortcut to close the drawer with the escape key
40 | */
41 | useShortcut( 'wp-feature-notifications/close-drawer', () => {
42 | if ( isActive ) toggle();
43 | } );
44 |
45 | return (
46 |
60 | );
61 | }
62 |
--------------------------------------------------------------------------------
/src/components/notice-area/footer/index.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from '@wordpress/components';
2 | import { __ } from '@wordpress/i18n';
3 | import { cog } from '@wordpress/icons';
4 |
5 | import { settingsPageUrl } from '../../../store/constants';
6 |
7 | /**
8 | * The footer for the notices section drawer.
9 | * Has a button that links to the settings page.
10 | */
11 | export default function NoticeAreaFooter() {
12 | return (
13 |
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/src/components/notice-area/index.tsx:
--------------------------------------------------------------------------------
1 | import { useSelect } from '@wordpress/data';
2 | import { __ } from '@wordpress/i18n';
3 |
4 | import store from '../../store';
5 | import { defaultContext } from '../../store/constants';
6 | import type { Notice } from '../../types';
7 | import { splitByDate } from '../../utils';
8 | import NoticeEmpty from '../notice-empty';
9 | import NoticesLoop from '../notice-loop';
10 |
11 | import Footer from './footer';
12 | import SectionHeader from './section-header';
13 |
14 | /**
15 | * The `NoticeArea` props type.
16 | */
17 | type Props = {
18 | context: string;
19 | notifications?: Notice[];
20 | };
21 |
22 | /**
23 | * WP Notification Feature toolbar in the secondary position of the admin bar
24 | * It watches for state updates and renders a component with the
25 | * updated state
26 | *
27 | * @param props
28 | * @param props.context Optional display context to render.
29 | * @param props.notifications The collection of notices to render.
30 | */
31 | export default function NoticesArea( {
32 | context = defaultContext,
33 | notifications,
34 | }: Props ) {
35 | /*
36 | * Todo: this method should supply to rest api the user data, current page, moreover the request args may be added (notice per page, notice filters and sort)
37 | */
38 | notifications = useSelect(
39 | ( select ) => select( store ).getNotices( context ),
40 | [ context ]
41 | );
42 |
43 | /**
44 | * if the context is the adminbar we need to render a list of notifications with the recent notifications and the old notifications
45 | */
46 | if ( context === defaultContext ) {
47 | /** Returns the empty notice banner whenever the number of notices is 0 */
48 | if ( ! notifications?.length ) {
49 | return ;
50 | }
51 |
52 | /** split the notifications by date */
53 | const sorted = splitByDate( notifications );
54 |
55 | return (
56 | <>
57 | { sorted.map( ( list, index ) => (
58 |
64 | notice.status === 'new'
69 | ).length || 0
70 | }
71 | isMain={ index === 0 } // the main section is the first one
72 | />
73 |
74 |
75 | ) ) }
76 |
77 | >
78 | );
79 | }
80 |
81 | /**
82 | * if the context is NOT the adminbar we need to render a simple list of notifications
83 | */
84 | return ;
85 | }
86 |
--------------------------------------------------------------------------------
/src/components/notice-area/section-header/index.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from '@wordpress/components';
2 | import { __ } from '@wordpress/i18n';
3 | import { check } from '@wordpress/icons';
4 |
5 | import { clearNotificationsDrawer } from '../../../utils';
6 |
7 | /**
8 | * The `NoticeAreaSectionHeader` props type.
9 | */
10 | type Props = {
11 | isMain: boolean;
12 | unreadCount: number;
13 | context: string;
14 | };
15 |
16 | /**
17 | * The section header for the notices section drawer.
18 | *
19 | * @param props
20 | * @param props.isMain
21 | * @param props.unreadCount
22 | * @param props.context
23 | */
24 | export default function NoticeAreaSectionHeader( {
25 | isMain,
26 | unreadCount,
27 | context,
28 | }: Props ) {
29 | return isMain ? (
30 |
31 | { unreadCount } new notifications
32 |
40 | ) : (
41 |
42 | Older notifications
43 |
44 | );
45 | }
46 |
--------------------------------------------------------------------------------
/src/components/notice-empty/index.tsx:
--------------------------------------------------------------------------------
1 | import { comment, Icon } from '@wordpress/icons';
2 |
3 | /**
4 | * The `NoticeEmpty` props type.
5 | */
6 | type Props = {
7 | message: string;
8 | size: number;
9 | };
10 |
11 | /**
12 | * @param props
13 | * @param props.message The message of the notification.
14 | * @param props.size The size of the icon.
15 | */
16 | export default function NoticeEmpty( { message, size }: Props ) {
17 | return (
18 |
22 |
23 | { message }
24 |
25 | );
26 | }
27 |
--------------------------------------------------------------------------------
/src/components/notice-loop/index.tsx:
--------------------------------------------------------------------------------
1 | import type { Notice } from '../../types';
2 | import NoticeComponent from '../notice';
3 |
4 | /**
5 | * The ` NoticeLoop` props type.
6 | */
7 | type Props = {
8 | notices: Notice[];
9 | };
10 |
11 | /**
12 | * Returns a list of notices
13 | * each notice is a component that has a key, an id, an image, an additional class
14 | * name, and an onDismiss function
15 | *
16 | * @param prop
17 | * @param prop.notices An array of objects that contain the notification data.
18 | *
19 | * @return An array of Notice components.
20 | */
21 | export default function NoticesLoop( { notices }: Props ) {
22 | return (
23 | <>
24 | { notices.map( ( notice ) => (
25 |
26 | ) ) }
27 | >
28 | );
29 | }
30 |
--------------------------------------------------------------------------------
/src/components/notice/actions/index.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from '@wordpress/components';
2 | import { __ } from '@wordpress/i18n';
3 |
4 | import { defaultContext } from '../../../store/constants';
5 | import type { NoticeAction } from '../../../types';
6 |
7 | /**
8 | * The `NoticeActions` props type.
9 | */
10 | type Props = {
11 | action: NoticeAction;
12 | context: string;
13 | onDismiss: Function;
14 | };
15 |
16 | /**
17 | * Renders an image or icon based on the type of notification
18 | *
19 | * @param param
20 | * @param param.action The action properties.
21 | * @param param.context The notification context.
22 | * @param param.onDismiss The callback to be called when the notice is dismissed
23 | */
24 | export default function NoticeActions( { action, context, onDismiss }: Props ) {
25 | const {
26 | acceptLink = '#',
27 | acceptMessage = __( 'Accept' ),
28 | dismissLabel = __( 'Dismiss' ),
29 | dismissible = false,
30 | } = action;
31 |
32 | if ( context === defaultContext ) {
33 | return (
34 |
35 |
43 | { dismissible ? (
44 |
51 | ) : null }
52 |
53 | );
54 | }
55 |
56 | return (
57 |
58 |
65 | { dismissible ? (
66 |
74 | ) : null }
75 |
76 | );
77 | }
78 |
--------------------------------------------------------------------------------
/src/components/notice/icon/index.tsx:
--------------------------------------------------------------------------------
1 | // @ts-ignore
2 | import classNames from 'classnames';
3 |
4 | import { defaultContext } from '../../../store/constants';
5 | import type { NoticeIcon } from '../../../types';
6 | import { isDashiconsIcon, isImageIcon, isSvgIcon } from '../../../utils/guards';
7 | import { purify } from '../../../utils/sanitization';
8 |
9 | /**
10 | * The `NoticeIcon` props type.
11 | */
12 | type Props = {
13 | context?: string;
14 | icon: NoticeIcon;
15 | severity?: string;
16 | };
17 |
18 | /**
19 | * It returns a div with a class name of `wp-notification-image` and `wp-notification-`
20 | * plus the type of image passed in an image or a svg element depending on the type
21 | * of image passed in.
22 | *
23 | * @param props
24 | * @param props.context The display context of the notification.
25 | * @param props.icon The icon of the notification.
26 | * @param props.severity The severity of the notification.
27 | */
28 | export default function NoticeIcon( {
29 | context = defaultContext,
30 | icon,
31 | severity,
32 | }: Props ) {
33 | /** build the notice container css classes definition */
34 | const classes = classNames(
35 | 'wp-notification-image',
36 | ! isDashiconsIcon( icon ) || 'wp-notification-icon',
37 | 'wp-notification-' + ( context || 'adminbar' )
38 | );
39 |
40 | let image: JSX.Element;
41 |
42 | // TODO: maybe is better to have a default definition like {type: "svg"} ?
43 | if ( isSvgIcon( icon ) ) {
44 | /** Since we don't want to double wrap svg's we need to return the div immediately */
45 | return (
46 |
50 | );
51 | } else if ( isDashiconsIcon( icon ) ) {
52 | image = (
53 |
60 | );
61 | } else if ( isImageIcon( icon ) ) {
62 | image = (
63 |
64 |
65 |
66 | );
67 | } else {
68 | return null;
69 | }
70 |
71 | /** and then finally we can return the image / icon wrapped into a container */
72 | return { image }
;
73 | }
74 |
--------------------------------------------------------------------------------
/src/components/notice/index.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * Single Notification UI component
3 | * https://github.com/WordPress/wp-feature-notifications/issues/16#issuecomment-896031592
4 | * https://github.com/WordPress/wp-feature-notifications/issues/37#issuecomment-896080025
5 | */
6 |
7 | import { dispatch } from '@wordpress/data';
8 | import classnames from 'classnames';
9 |
10 | import { STORE_NAMESPACE } from '../../constants';
11 | import { defaultContext } from '../../store/constants';
12 | import type { Notice } from '../../types';
13 | import { delay } from '../../utils';
14 | import { purify } from '../../utils/sanitization';
15 |
16 | import NoticeActions from './actions';
17 | import NoticeIcon from './icon';
18 | import NoticeMeta from './meta';
19 |
20 | /**
21 | * This is a functional component in JavaScript that defines the UI for a single
22 | * notification. It takes in a set of props, destructures them, and uses them to
23 | * render the notification with the appropriate title, message, icon, and actions.
24 | * It also includes a function to dismiss the notification when the user clicks on
25 | * the dismiss button.
26 | *
27 | * @param props The notification props to render.
28 | */
29 | export default function ( props: Notice ) {
30 | const {
31 | action,
32 | context = defaultContext,
33 | date = new Date(),
34 | dismissLabel,
35 | dismissible,
36 | icon,
37 | id,
38 | message,
39 | severity,
40 | source = 'WordPress',
41 | status,
42 | title,
43 | } = props;
44 |
45 | /**
46 | * Dismiss the target notification
47 | */
48 | function dismissNotice() {
49 | dispatch( STORE_NAMESPACE ).updateNotice( {
50 | id,
51 | status: 'dismissed',
52 | } );
53 | // TODO missing exit animation
54 | delay( 50 ).then( () =>
55 | dispatch( STORE_NAMESPACE ).removeNotice( id )
56 | );
57 | }
58 |
59 | return (
60 |
69 |
70 |
{ title }
71 | { message ? (
72 |
73 | ) : null }
74 |
79 |
80 |
81 |
82 |
87 |
88 | );
89 | }
90 |
--------------------------------------------------------------------------------
/src/components/notice/meta/index.tsx:
--------------------------------------------------------------------------------
1 | import { formatDate } from '../../../utils';
2 |
3 | /**
4 | * The `NoticeMeta` props type.
5 | */
6 | type Props = {
7 | date: Date;
8 | source: string;
9 | };
10 |
11 | /**
12 | * The notice metadata, for example the source of the notification or the date
13 | *
14 | * @param props
15 | * @param props.date The date of the notification.
16 | * @param props.source The source of the notification.
17 | */
18 | export default function NoticeMeta( { date, source }: Props ) {
19 | return (
20 |
21 | { source } { '\u2022 ' }
22 | { formatDate( date ) }
23 |
24 | );
25 | }
26 |
--------------------------------------------------------------------------------
/src/components/notification-hub/index.tsx:
--------------------------------------------------------------------------------
1 | import { useDispatch } from '@wordpress/data';
2 | import { useEffect, useRef, useState } from '@wordpress/element';
3 | import { __ } from '@wordpress/i18n';
4 | import {
5 | ShortcutProvider,
6 | store as keyboardShortcutsStore,
7 | } from '@wordpress/keyboard-shortcuts';
8 | import classNames from 'classnames';
9 |
10 | import Drawer from '../drawer';
11 | import HubIcon from '../hub-icon';
12 |
13 | /**
14 | * The HTML rendered by this component is the same as the variable `notification_hub`
15 | * in`includes/load.php`. If the output of this component is modified that file must
16 | * also be updated.
17 | */
18 |
19 | /**
20 | * The `NotificationHub` props type.
21 | */
22 | type Props = {
23 | initialActive?: boolean;
24 | };
25 |
26 | /**
27 | * The notification hub component.
28 | *
29 | * @param props
30 | * @param props.initialActive Optionally initially force the hub into an active state.
31 | */
32 | export default function NotificationHub( { initialActive = false }: Props ) {
33 | /** Drawer state */
34 | const [ isActive, setIsActive ] = useState( initialActive );
35 | const drawerRef = useRef< HTMLElement | null >( null );
36 |
37 | /** Register the keyboard shortcut(s) */
38 | const { registerShortcut } = useDispatch( keyboardShortcutsStore );
39 |
40 | useEffect( () => {
41 | registerShortcut( {
42 | name: 'wp-feature-notifications/close-drawer',
43 | category: 'wp-feature-notifications',
44 | description: __( 'Close the Notification drawer' ),
45 | keyCombination: {
46 | character: 'Escape',
47 | },
48 | } );
49 | } );
50 |
51 | const toggleDrawer = () => {
52 | setIsActive( ( prev ) => ! prev );
53 | };
54 |
55 | const handleOutsideClick = ( event: MouseEvent ) => {
56 | if (
57 | drawerRef.current &&
58 | ! drawerRef.current.contains( event.target as Node )
59 | ) {
60 | setIsActive( false );
61 | }
62 | };
63 |
64 | useEffect( () => {
65 | if ( isActive ) {
66 | document.addEventListener( 'mousedown', handleOutsideClick );
67 | return () => {
68 | document.removeEventListener( 'mousedown', handleOutsideClick );
69 | };
70 | }
71 | }, [ isActive ] );
72 |
73 | return (
74 |
75 |
81 |
86 | setIsActive( true ) }
89 | blur={ () => setIsActive( false ) }
90 | />
91 |
92 |
93 | );
94 | }
95 |
--------------------------------------------------------------------------------
/src/components/unread-dot/index.tsx:
--------------------------------------------------------------------------------
1 | import classNames from 'classnames';
2 |
3 | /**
4 | * The `UnreadDot` props type.
5 | */
6 | type Props = {
7 | isActive: boolean;
8 | };
9 |
10 | /**
11 | * Notification icon unread dot UI component.
12 | *
13 | * @param props
14 | * @param props.isActive Predicate of whether the unread dot is in an active state.
15 | */
16 | export default function UnreadDot( { isActive }: Props ) {
17 | return (
18 |
24 | );
25 | }
26 |
--------------------------------------------------------------------------------
/src/constants.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * WP Notification Feature namespace.
3 | */
4 | export const STORE_NAMESPACE = 'core/wp-notifications';
5 |
6 | /**
7 | * API_PATH WP Notification Feature rest api path.
8 | */
9 | export const API_PATH = '/wp-notifications/v1/notifications/';
10 |
11 | /**
12 | * The width of the notification hub.
13 | */
14 | export const HUB_WIDTH = 320;
15 |
16 | /**
17 | * The number of milliseconds in a week.
18 | */
19 | export const WEEK_IN_MILLISECONDS = 604_800_000;
20 |
--------------------------------------------------------------------------------
/src/store/actions.ts:
--------------------------------------------------------------------------------
1 | import type { Notice } from '../types';
2 |
3 | /**
4 | * Action creator to hydrate a notice from the store.
5 | *
6 | * @param payload The notices to hydrate.
7 | * @return A redux action.
8 | */
9 | export const hydrate = ( payload: Notice[] ) => {
10 | return {
11 | type: 'HYDRATE' as const,
12 | payload,
13 | };
14 | };
15 |
16 | /**
17 | * Action creator to clear a notification context from the store.
18 | *
19 | * @param context The slug of the context to clear.
20 | * @return A redux action.
21 | */
22 | export const clear = ( context: string ) => {
23 | return {
24 | type: 'CLEAR' as const,
25 | context,
26 | };
27 | };
28 |
29 | /**
30 | * Action creator to add a notice to the store.
31 | *
32 | * @param payload The notice to add.
33 | * @return A redux action.
34 | */
35 | export const addNotice = ( payload: Notice ) => {
36 | return {
37 | type: 'ADD' as const,
38 | payload,
39 | };
40 | };
41 |
42 | /**
43 | * Action creator to remove a notice from the store.
44 | *
45 | * @param id The id of the notice to remove.
46 | * @return A redux action.
47 | */
48 | export const removeNotice = ( id: number ) => {
49 | return {
50 | type: 'DELETE' as const,
51 | id,
52 | };
53 | };
54 |
55 | /**
56 | * Action creator to update a notice in the store.
57 | *
58 | * @param payload
59 | * @return A redux action.
60 | */
61 | export const updateNotice = (
62 | payload: Pick< Notice, 'id' > | Partial< Notice >
63 | ) => {
64 | return {
65 | type: 'UPDATE' as const,
66 | payload,
67 | };
68 | };
69 |
70 | /**
71 | * Action creator to fetch notices.
72 | *
73 | * @param path The REST API route from which to fetch notices.
74 | * @return A redux action.
75 | */
76 | export const fetchAPI = ( path = '' ) => {
77 | return {
78 | type: 'FETCH' as const,
79 | path,
80 | };
81 | };
82 |
--------------------------------------------------------------------------------
/src/store/constants.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * The WP Notification Feature default context.
3 | */
4 | export const defaultContext = 'adminbar';
5 |
6 | /**
7 | * The WP Notification Feature default contexts
8 | */
9 | export const contexts = [ defaultContext, 'dashboard' ] as const;
10 |
11 | /**
12 | * The url of the notifications settings page
13 | */
14 | export const settingsPageUrl =
15 | // eslint-disable-next-line camelcase
16 | typeof window.wp_notifications_data !== 'undefined'
17 | ? window.wp_notifications_data?.settingsPage
18 | : '';
19 |
--------------------------------------------------------------------------------
/src/store/controls.ts:
--------------------------------------------------------------------------------
1 | import apiFetch from '@wordpress/api-fetch';
2 |
3 | import { API_PATH } from '../constants';
4 | import type { Notice } from '../types';
5 |
6 | /**
7 | * Fetches the wp-notifications rest api endpoint for the specified endpoint
8 | *
9 | * @param action The action to execute
10 | * @return The Promise with the results
11 | */
12 | export function FETCH< Action extends { path: string } >( action: Action ) {
13 | return apiFetch( {
14 | path: API_PATH + action.path,
15 | } ).then( ( notices: any[] ) =>
16 | notices.map(
17 | ( notice ) =>
18 | ( {
19 | ...notice,
20 | date: new Date( notice.date ),
21 | } ) as Notice
22 | )
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/src/store/index.ts:
--------------------------------------------------------------------------------
1 | import { createReduxStore, register } from '@wordpress/data';
2 |
3 | import { STORE_NAMESPACE } from '../constants';
4 | import type { Notice } from '../types';
5 |
6 | import * as actions from './actions';
7 | import * as controls from './controls';
8 | import reducer from './reducer';
9 | import * as resolvers from './resolvers';
10 | import * as selectors from './selectors';
11 |
12 | /**
13 | * The notifications redux store type.
14 | */
15 | export type State = Record< string, Notice[] >;
16 |
17 | type ValuesOf<
18 | T extends Record< string, unknown >,
19 | K extends keyof T = keyof T,
20 | > = T[ K ];
21 |
22 | /**
23 | * The actions of the notifications redux reducer.
24 | */
25 | export type Action = ReturnType< ValuesOf< typeof actions > >;
26 |
27 | /**
28 | * Creating a store for the redux state.
29 | *
30 | * A Redux store that lets you read the state, dispatch actions and subscribe to changes.
31 | */
32 | const store = createReduxStore< State, typeof actions, typeof selectors >(
33 | STORE_NAMESPACE,
34 | {
35 | reducer,
36 | actions,
37 | selectors,
38 | controls,
39 | resolvers,
40 | }
41 | );
42 |
43 | register( store );
44 |
45 | export type NoticeStore = typeof store;
46 | export default store;
47 |
--------------------------------------------------------------------------------
/src/store/reducer.ts:
--------------------------------------------------------------------------------
1 | import type { Reducer } from 'redux';
2 |
3 | import { findContext } from './utils';
4 |
5 | import type { Action, State } from './index';
6 |
7 | /**
8 | * Reducer returning the next notices state. The notices state is an object
9 | * where each key is a context, its value an array of notice objects.
10 | *
11 | * @param state The notifications redux state.
12 | * @param action The current action.
13 | */
14 | const reducer: Reducer< State, Action > = ( state = {}, action ) => {
15 | switch ( action.type ) {
16 | case 'HYDRATE': {
17 | let updated = { ...state };
18 | action.payload.forEach( ( notification ) => {
19 | const context = notification.context || 'adminbar';
20 | updated = {
21 | ...updated,
22 | [ context ]: [ ...updated[ context ], notification ],
23 | };
24 | } );
25 | return updated;
26 | }
27 | case 'ADD': {
28 | return {
29 | ...state,
30 | [ action.payload.context ]: [
31 | ...state[ action.payload.context ],
32 | action.payload,
33 | ],
34 | };
35 | }
36 | case 'DELETE': {
37 | const context = findContext( state, action.id );
38 | return {
39 | ...state,
40 | [ context ]: state[ context ].filter(
41 | ( notice ) => notice.id !== action.id
42 | ),
43 | };
44 | }
45 | case 'CLEAR': {
46 | state[ action.context ] = [];
47 | return { ...state };
48 | }
49 | case 'UPDATE': {
50 | const context = findContext( state, action.payload.id );
51 | return {
52 | ...state,
53 | [ context ]: state[ context ].map( ( notice ) =>
54 | notice.id === action.payload.id
55 | ? { ...notice, ...action.payload } // merge the new object with the old object
56 | : notice
57 | ),
58 | };
59 | }
60 | }
61 |
62 | return state;
63 | };
64 |
65 | export default reducer;
66 |
--------------------------------------------------------------------------------
/src/store/resolvers.ts:
--------------------------------------------------------------------------------
1 | import { fetchAPI, hydrate } from './actions';
2 |
3 | /**
4 | * Fetch the rest api in order to get new notifications
5 | */
6 | export const fetchUpdates = function* () {
7 | const newNotifications = yield fetchAPI();
8 |
9 | if ( newNotifications ) {
10 | return hydrate( newNotifications );
11 | }
12 | };
13 |
--------------------------------------------------------------------------------
/src/store/selectors.ts:
--------------------------------------------------------------------------------
1 | import { __ } from '@wordpress/i18n';
2 |
3 | import type { Notice } from '../types';
4 |
5 | import { findContext } from './utils';
6 |
7 | import type { State } from './index';
8 |
9 | /**
10 | * Fetch the rest api in order to get new notifications
11 | *
12 | * @param state the current state
13 | * @return the new notifications
14 | */
15 | export const fetchUpdates = ( state: State ): State => state || {};
16 |
17 | /**
18 | * Get the notices for the given context
19 | *
20 | * @param state the current state
21 | * @param context the name of the list of notifications you want to retrieve
22 | *
23 | * @return The list of notices of the context
24 | */
25 | export const getNotices = (
26 | state: State,
27 | context: string
28 | ): Notice[] | null => {
29 | return context ? state[ context ] : null;
30 | };
31 |
32 | /**
33 | * Adds a context to the current state.
34 | * commonly it's fired when the NotificationArea is registered
35 | *
36 | * @param state The current state.
37 | * @param context The context to add.
38 | *
39 | * @return the notice store state
40 | */
41 | export const registerContext = ( state: State, context: string ): State => {
42 | if ( ! state[ context ] ) {
43 | state[ context ] = [];
44 | }
45 | return state;
46 | };
47 |
48 | /**
49 | * It searches the Redux store for a notification by ID or by a search term
50 | *
51 | * @param state The current state.
52 | * @param searchTerm The term to search for.
53 | * @param args The search args.
54 | *
55 | * @return The search result
56 | */
57 | export const findNotice = (
58 | state: State,
59 | searchTerm: string | number,
60 | args = { term: 'source' }
61 | ): Notice | Notice[] | string => {
62 | // return the notification by id
63 | if ( typeof searchTerm === 'number' ) {
64 | const context = findContext( state, searchTerm );
65 | return state[ context ].find( ( notice ) => notice.id === searchTerm );
66 | }
67 |
68 | // Search the notification by key and searchTerm
69 | if ( typeof searchTerm === 'string' ) {
70 | const searchFor = args.term || 'source';
71 | searchTerm = searchTerm.toLowerCase();
72 | // merge all Object state Items into a single state item
73 | const found = Object.values( state )
74 | .flat()
75 | .filter(
76 | ( el ) =>
77 | el[ searchFor ] &&
78 | el[ searchFor ].toLowerCase().includes( searchTerm )
79 | );
80 |
81 | if ( found.length ) {
82 | return found;
83 | }
84 | }
85 | return __( 'nothing was found' );
86 | };
87 |
--------------------------------------------------------------------------------
/src/store/utils.ts:
--------------------------------------------------------------------------------
1 | import type { State } from './index';
2 |
3 | /**
4 | * Find the context for the given notification key.
5 | *
6 | * @param notifications - The notifications object to search in
7 | * @param id - The notification id to search
8 | */
9 | export function findContext( notifications: State, id: number ) {
10 | for ( const location in notifications ) {
11 | const found = notifications[ location ].find(
12 | ( notification ) => notification.id === id
13 | );
14 | if ( found ) {
15 | return location;
16 | }
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/src/styles/components/bullet.scss:
--------------------------------------------------------------------------------
1 | /*
2 | * Elements - Bullet
3 | */
4 |
5 | $bullet-width: 6px;
6 |
7 | #wpbody .wp-notification-wrap,
8 | #wpadminbar .wp-notification.new {
9 | position: relative;
10 |
11 | > :first-child::before {
12 | content: "";
13 | display: block;
14 | position: absolute;
15 | width: $bullet-width;
16 | height: $bullet-width;
17 |
18 | top: calc(10px - #{$bullet-width * 0.5 });
19 | left: #{ (-36px - $bullet-width) * 0.5 }; // 9 - left padding - border /2 + bullet/2
20 |
21 | border-radius: 50%;
22 | background: var(--wp-notifications--color--link);
23 | }
24 | }
25 |
26 | #wpadminbar .wp-notification.new > :first-child::before {
27 | top: calc(var(--wp-notifications--hub-image-size) * 0.5 + var(--wp-notifications--hub-spacing-top));
28 | left: 2px;
29 | }
30 |
--------------------------------------------------------------------------------
/src/styles/components/notification.scss:
--------------------------------------------------------------------------------
1 | /*
2 | * General notifications style
3 | */
4 | .wp-notification {
5 | background: var(--wp-notifications--color--background);
6 | color: var(--wp-notifications--color--primary);
7 | overflow: hidden;
8 | max-height: 600px;
9 |
10 | &-action {
11 | text-decoration-line: underline;
12 | color: var(--wp-notifications--color--link);
13 | }
14 |
15 | &-meta {
16 | color: var(--wp-notifications--color--secondary);
17 | }
18 |
19 | // TODO exit animation
20 |
21 | /* &.dismissing {
22 | transition: opacity 100ms, padding 100ms, max-height 100ms;
23 | padding-top: 0 !important;
24 | padding-bottom: 0 !important;
25 | max-height: 0;
26 | opacity: 0;
27 | }*/
28 | }
29 |
--------------------------------------------------------------------------------
/src/styles/dashboard/notice.scss:
--------------------------------------------------------------------------------
1 | /*
2 | * Dashboard notifications
3 | */
4 | #wpbody {
5 |
6 | #wp-notifications-dashboard-notices {
7 | clear: both;
8 | }
9 |
10 | .wp-notification {
11 | --wp-notifications--hub-spacing-top: clamp(1em, 3vh, 2em);
12 | --wp-notifications--hub-element-bottom: clamp(1em, 3vh, 2em);
13 | --wp-notifications--hub-spacing-gap: clamp(2em, 3vh, 3em);
14 |
15 | width: 100%;
16 | box-sizing: border-box;
17 |
18 | display: grid;
19 | grid-template-rows: auto;
20 | grid-template-columns: 1fr 200px;
21 | grid-auto-flow: row;
22 |
23 | justify-content: space-between;
24 |
25 | padding: var(--wp-notifications--hub-spacing-top) 20px var(--wp-notifications--hub-spacing-gap) 36px;
26 | margin: 10px 0 var(--wp-notifications--hub-element-bottom);
27 | box-shadow: 0 1px 1px rgba(0, 0, 0, 0.04);
28 |
29 | // the notification left border
30 | border-left-width: 4px;
31 | border-left-style: solid;
32 | border-color: var(--wp-notifications--color--primary);
33 |
34 | @media #{$breakpoint} {
35 | grid-template-columns: auto;
36 | gap: 50px;
37 |
38 | .wp-notification-wrap {
39 | order: 2;
40 | }
41 | }
42 |
43 | /**
44 | * Will removes the top-margin from the first
45 | * and bottom-margin from the last dash notifications
46 | */
47 | &-wrap {
48 | display: flex;
49 | flex-direction: column;
50 |
51 | > :first-child {
52 | margin-top: 0;
53 | }
54 |
55 | > :last-child {
56 | margin-bottom: 0;
57 | }
58 | }
59 |
60 | // join subsequent notifications
61 | + .wp-notification {
62 | margin-top: calc(var(--wp-notifications--hub-element-bottom) * -1);
63 | border-top: none;
64 | }
65 |
66 | // notification action buttons
67 | &-actions {
68 | display: flex;
69 | margin-top: auto;
70 |
71 | .wp-notification-action {
72 | padding: 6px 32px;
73 | text-decoration: none;
74 | }
75 |
76 | .button-tertiary {
77 | padding-left: 16px;
78 | padding-right: 16px;
79 | background-color: transparent;
80 | border: none;
81 |
82 | &:hover,
83 | &:focus {
84 | background-color: $color-black-300;
85 | }
86 | }
87 | }
88 |
89 | &-image figure {
90 | max-width: 200px;
91 | max-height: 200px;
92 | margin: 0;
93 |
94 | img {
95 | width: 100%;
96 | object-position: center;
97 | object-fit: contain;
98 | }
99 | }
100 | }
101 | }
102 |
--------------------------------------------------------------------------------
/src/styles/hub/admin-bar.scss:
--------------------------------------------------------------------------------
1 | /* stylelint-disable font-family-no-missing-generic-family-keyword */
2 |
3 | /*
4 | * WP admin bar
5 | */
6 | #wpadminbar #wp-admin-bar-wp-notifications-hub {
7 | padding-right: 8px;
8 | position: relative;
9 | z-index: 100002;
10 | display: block; // avoids to hide the bell icon (and the drawer) on mobile
11 |
12 | /**
13 | * Drawer icons needs to be reset to avoid admin-bar default icon color
14 | */
15 | &:hover #wp-notifications-hub {
16 |
17 | .ab-icon::before {
18 | color: $color-white;
19 | }
20 | }
21 |
22 | /* the bell icon */
23 | .hub-icon {
24 | appearance: none;
25 | width: 28px;
26 | height: 32px;
27 | display: block;
28 | border: 0;
29 | margin: 0;
30 | background: none;
31 |
32 | }
33 |
34 | /* if the drawer is enabled transforms the bell icon to overlay the drawer */
35 | .notifications.active > .hub-icon .ab-icon {
36 | filter: invert(1) hue-rotate(180deg);
37 | position: relative;
38 | z-index: 100002;
39 | }
40 |
41 | /* The red dot over the bell */
42 | .unread-dot {
43 | position: absolute;
44 | width: 6px;
45 | height: 6px;
46 | top: 12px;
47 | left: 20px;
48 | border-radius: 50%;
49 | font-size: 0;
50 |
51 | /* the dot color */
52 | background: $color-red;
53 |
54 | /* the dot color border */
55 | border: 1px solid $color-black-800;
56 |
57 | /* initially it is hidden */
58 | opacity: 0;
59 |
60 | transition: opacity 0.175s;
61 |
62 | &.has-unread {
63 | opacity: 1;
64 | }
65 | }
66 |
67 | /* Mobile */
68 | @media #{$breakpoint} {
69 | display: block;
70 |
71 | .hub-icon {
72 | width: 54px;
73 | height: 46px;
74 |
75 | .ab-icon {
76 | padding-top: 6px;
77 | font: 32px/1 dashicons !important;
78 | }
79 | }
80 |
81 | .unread-dot {
82 | clip: unset;
83 | clip-path: unset;
84 | width: 9px;
85 | height: 9px;
86 | top: 16px;
87 | left: 28px;
88 | }
89 | }
90 | }
91 |
--------------------------------------------------------------------------------
/src/styles/hub/elements.scss:
--------------------------------------------------------------------------------
1 | /*
2 | * Hub Elements style (header, footer etc)
3 | */
4 | #wp-notifications-hub {
5 |
6 | * {
7 | margin: 0;
8 | padding: 0;
9 | line-height: 1.5;
10 | box-sizing: border-box;
11 | }
12 |
13 | h2 {
14 | font-weight: 700;
15 | font-size: 1.2rem;
16 | margin: 0;
17 | }
18 |
19 | /*
20 | * Hub HEADER
21 | */
22 | header {
23 | position: sticky;
24 | z-index: 2;
25 | min-height: 50px;
26 | top: 0;
27 | background: $color-white;
28 | box-shadow: 0 0 0 1px $color-white;
29 | padding: 0 var(--wp-notifications--hub-spacing-horizontal) 10px;
30 |
31 | .wp-notifications-action.mark-as-read {
32 |
33 | svg {
34 | vertical-align: middle;
35 | }
36 | }
37 | }
38 |
39 | /*
40 | * Hub FOOTER
41 | */
42 | #wpadminbar & footer {
43 | position: fixed;
44 | bottom: 0;
45 | left: 0;
46 | right: 0;
47 | display: flex;
48 | justify-content: center;
49 | width: 100%;
50 | align-items: center;
51 |
52 | // The shadow at the end of sidebar
53 | &::before {
54 | content: "";
55 | display: block;
56 | box-shadow: 0 1rem 24px 2px var(--wp-notifications--color--primary);
57 | width: 80%;
58 | margin: auto 10%;
59 | position: absolute;
60 | z-index: -1;
61 | top: 0;
62 | }
63 |
64 | &:focus {
65 | outline: none;
66 | }
67 |
68 | .components-button {
69 | line-height: 1;
70 | height: 56px;
71 | width: 100%;
72 | text-align: center;
73 | background: var(--wp-notifications--color--background);
74 | padding: 15px var(--wp-notifications--hub-spacing-horizontal);
75 | border-top: 1px solid $color-black-400;
76 |
77 | svg {
78 | fill: var(--wp-notifications--color--link);
79 | vertical-align: middle;
80 | }
81 |
82 | &:hover,
83 | &:focus {
84 | color: var(--wp-notifications--color--link-active);
85 |
86 | svg {
87 | fill: var(--wp-notifications--color--link-active);
88 | }
89 | }
90 |
91 | // Outline the footer element if it is selected with tab
92 | &:focus {
93 | border-radius: 2px;
94 | outline: 2px solid $color-black;
95 | }
96 | }
97 | }
98 | }
99 |
--------------------------------------------------------------------------------
/src/styles/hub/layout.scss:
--------------------------------------------------------------------------------
1 | /*
2 | * Notification Hub Layout
3 | */
4 | #wp-notifications-hub {
5 | position: fixed;
6 | right: 0;
7 | top: 0;
8 | z-index: 100001;
9 | display: flex;
10 | flex-direction: column;
11 | height: 100%;
12 | padding: 30px 0 0;
13 | background: var(--wp-notifications--color--background);
14 | box-shadow: 0 0 24px -16px var(--wp-notifications--color--primary);
15 | box-sizing: border-box;
16 | overflow: hidden;
17 |
18 | // 👇 the drawer open/close state
19 | transform: translateX(100%);
20 | opacity: 0;
21 | transition: transform 350ms, opacity 250ms;
22 |
23 | .notifications.active & {
24 | opacity: 1;
25 | transform: translateX(0);
26 | padding-bottom: 0; // moves the shadow at the end of sidebar
27 | }
28 |
29 | .hub-wrapper {
30 | height: inherit;
31 | overflow: auto;
32 | padding-bottom: 56px;
33 |
34 | .wp-notifications-hub-section {
35 | position: relative;
36 | padding-bottom: 1rem;
37 | }
38 |
39 | // Scrollbar customization
40 | &::-webkit-scrollbar {
41 | width: 18px; // width = scrollbar width + border * 2
42 | height: 48px;
43 |
44 | &-thumb {
45 | height: 6px;
46 | border: 6px solid transparent;
47 | background-clip: padding-box;
48 | -webkit-border-radius: 18px;
49 | background-color: var(--wp-notifications--color--scrollbar);
50 | }
51 | }
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/src/styles/hub/notice.scss:
--------------------------------------------------------------------------------
1 | /*
2 | * The single notification
3 | */
4 | #wp-notifications-hub {
5 |
6 | .wp-notification {
7 | max-width: 100%;
8 | margin-left: var(--wp-notifications--hub-spacing-gap);
9 | margin-right: var(--wp-notifications--hub-spacing-gap);
10 | user-select: none;
11 | -webkit-user-drag: none;
12 |
13 | padding: var(--wp-notifications--hub-spacing-top) var(--wp-notifications--hub-spacing-horizontal) var(--wp-notifications--hub-spacing-gap);
14 |
15 | gap: 12px;
16 | display: grid;
17 | grid-template-rows: auto;
18 | grid-template-columns: var(--wp-notifications--hub-image-size) auto;
19 | grid-auto-flow: row;
20 |
21 | // shows the selected item using the webkit default styling
22 | &:focus-visible {
23 | border-radius: 2px;
24 | outline: 2px solid $color-black;
25 | }
26 |
27 | &-wrap {
28 | margin-top: -4px;
29 |
30 | > * {
31 | margin: 0 0 4px;
32 | }
33 | }
34 |
35 | &-title {
36 | font-weight: 600;
37 | line-height: 1.5;
38 | }
39 |
40 | &-action {
41 | display: block;
42 | border: 0;
43 | background: transparent;
44 | }
45 |
46 | &-actions {
47 | display: flex;
48 | gap: 0.5rem;
49 | }
50 |
51 | &-image {
52 | min-width: var(--wp-notifications--hub-image-size);
53 | width: var(--wp-notifications--hub-image-size);
54 | height: var(--wp-notifications--hub-image-size);
55 | position: relative;
56 | grid-row: 1;
57 |
58 | * {
59 | width: 100%;
60 | height: 100%;
61 | object-fit: cover;
62 | }
63 | }
64 |
65 | &-icon {
66 |
67 | background: $color-black-200;
68 | border-radius: 8px;
69 |
70 | .ab-icon {
71 | font-size: 24px;
72 | padding: 4px;
73 | line-height: 1;
74 |
75 | &::before {
76 | color: $color-white;
77 | opacity: 0.9;
78 | }
79 | }
80 | }
81 |
82 | /*
83 | * Notice type
84 | */
85 | &.user {
86 |
87 | .wp-notification-image img {
88 | border-radius: 8px;
89 | }
90 | }
91 | }
92 |
93 |
94 | /*
95 | * Notice Severity
96 | */
97 | $alertSeverity: (
98 | "alert": var(--wp-notifications--color--error),
99 | "warning": var(--wp-notifications--color--warning),
100 | "success": var(--wp-notifications--color--success),
101 | "info": var(--wp-notifications--color--info)
102 | );
103 |
104 | @each $key, $value in $alertSeverity {
105 | .wp-notification .severity-#{$key} {
106 | background-color: #{$value};
107 | }
108 | }
109 | }
110 |
--------------------------------------------------------------------------------
/src/styles/vars.scss:
--------------------------------------------------------------------------------
1 | // Mobile breakpoint used in wp-admin.
2 | $breakpoint: "screen and (max-width: 782px)";
3 |
4 | // WP Notification Feature base colors
5 | $color-yellow: #ffb900; // Yellow
6 | $color-green: #46b450; // Green
7 | $color-red: #d94f4f; // Alert Red
8 | $color-blue: #0071a1; // Blue Dark 900
9 | $color-blue-light: #72aee6; // Blue 20
10 |
11 | // WP Notification Feature base tones
12 | $color-white: #fff; // White
13 | $color-black-200: #e2e4e7; // Gray 200
14 | $color-black-300: #e8eaeb; // Gray 300
15 | $color-black-400: #c3c4c7; // Gray 400
16 | $color-black-500: #6c7781; // Gray 500
17 | $color-black-700: #444; // Gray 700
18 | $color-black-800: #23282d; // Gray 800
19 | $color-black: #000; // Black
20 |
21 | // https://make.wordpress.org/design/handbook/design-guide/foundations/colors/
22 | :root {
23 | --wp-notifications--color--primary: #{$color-black-700};
24 | --wp-notifications--color--secondary: #{$color-black-500};
25 | --wp-notifications--color--background: #{$color-white};
26 |
27 | --wp-notifications--color--scrollbar: #{$color-black-300};
28 | --wp-notifications--color--link: #{$color-blue};
29 | --wp-notifications--color--link-active: #{$color-blue-light};
30 | --wp-notifications--color--error: #{$color-red};
31 | --wp-notifications--color--info: #{$color-blue};
32 | --wp-notifications--color--warning: #{$color-yellow};
33 | --wp-notifications--color--success: #{$color-green};
34 |
35 | // WP Notification Feature hub (admin top bar)
36 | --wp-notifications--hub-width: 320px;
37 |
38 | @media #{$breakpoint} {
39 | --wp-notifications--hub-width: 100%;
40 | }
41 | --wp-notifications--hub-image-size: 32px;
42 |
43 | --wp-notifications--hub-spacing-top: 15px;
44 | --wp-notifications--hub-spacing-bottom: 17px;
45 | --wp-notifications--hub-spacing-horizontal: 16px;
46 | --wp-notifications--hub-spacing-gap: 8px;
47 | }
48 |
--------------------------------------------------------------------------------
/src/styles/wp-notifications.scss:
--------------------------------------------------------------------------------
1 | /**
2 | * WP Notification Feature Style
3 | */
4 |
5 | /* Vars */
6 | @import "vars";
7 |
8 | /* Components */
9 | @import "components/notification";
10 | @import "components/bullet";
11 |
12 | // Dashboard notifications
13 | @import "dashboard/notice";
14 |
15 | // Hub notifications
16 | @import "hub/admin-bar";
17 | @import "hub/layout";
18 | @import "hub/elements";
19 | @import "hub/notice";
20 |
--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * The notice action type
3 | */
4 | export type NoticeAction = {
5 | /**
6 | * The url of the action.
7 | */
8 | acceptLink?: string;
9 |
10 | /**
11 | * The label of the action.
12 | */
13 | acceptMessage?: string;
14 |
15 | /**
16 | * The label of the dismiss action.
17 | */
18 | dismissLabel?: string;
19 |
20 | /**
21 | * Predicate of whether the notification can be dismissed.
22 | */
23 | dismissible?: boolean;
24 | };
25 |
26 | /**
27 | * The Dashicons icon type.
28 | */
29 | export type DashiconsIcon = {
30 | /**
31 | * The Dashicons slug of the icon.
32 | */
33 | dashicons: string;
34 | };
35 |
36 | /**
37 | * The image icon type.
38 | */
39 | export type ImageIcon = {
40 | /**
41 | * The url of the image icon.
42 | */
43 | src: string;
44 | };
45 |
46 | /**
47 | * The SVG icon type.
48 | */
49 | export type SvgIcon = {
50 | /**
51 | * The SVG markup of the icon.
52 | */
53 | svg: string;
54 | };
55 |
56 | /**
57 | * The notification icon type.
58 | */
59 | export type NoticeIcon = DashiconsIcon | ImageIcon | SvgIcon;
60 |
61 | /**
62 | * The notification status type.
63 | */
64 | export type NoticeStatus = 'undisplayed' | 'displayed' | 'dismissed' | 'new';
65 |
66 | /**
67 | * The notification type.
68 | */
69 | export type Notice = {
70 | /**
71 | * The optional action associated to the notification.
72 | */
73 | action?: NoticeAction;
74 |
75 | /**
76 | * The rendering context of the notification.
77 | */
78 | context?: string;
79 |
80 | /**
81 | * The date from which the notification was emitted.
82 | */
83 | date: Date;
84 |
85 | /**
86 | * The label of the dismiss action.
87 | */
88 | dismissLabel?: string;
89 |
90 | /**
91 | * Predicate of whether the notification can be dismissed.
92 | */
93 | dismissible?: boolean;
94 |
95 | /**
96 | * The notification status type.
97 | */
98 | icon?: NoticeIcon;
99 |
100 | /**
101 | * The database id of the notification message.
102 | */
103 | id: number;
104 |
105 | /**
106 | * The message content of the notification.
107 | */
108 | message: string;
109 |
110 | /**
111 | * The severity of the notification.
112 | */
113 | severity?: string;
114 |
115 | /**
116 | * The source of the notification.
117 | */
118 | source?: string;
119 |
120 | /**
121 | * The status of the notification.
122 | */
123 | status?: NoticeStatus;
124 |
125 | /**
126 | * The title of the notification message.
127 | */
128 | title?: string;
129 | };
130 |
--------------------------------------------------------------------------------
/src/utils/guards.ts:
--------------------------------------------------------------------------------
1 | import type { DashiconsIcon, ImageIcon, SvgIcon } from '../types';
2 |
3 | /**
4 | * @param icon The icon to guard as a Dashicons icon.
5 | * @return Whether or not the icon is an Dashicons icon.
6 | */
7 | export const isDashiconsIcon = ( icon: unknown ): icon is DashiconsIcon => {
8 | return typeof ( icon as DashiconsIcon )?.dashicons === 'string';
9 | };
10 |
11 | /**
12 | * @param icon The icon to guard as a image icon.
13 | * @return Whether or not the icon is an image icon.
14 | */
15 | export const isImageIcon = ( icon: unknown ): icon is ImageIcon => {
16 | return typeof ( icon as ImageIcon )?.src === 'string';
17 | };
18 |
19 | /**
20 | * @param icon The icon to guard as a SVG icon.
21 | * @return Whether or not the icon is an SVG icon.
22 | */
23 | export const isSvgIcon = ( icon: unknown ): icon is SvgIcon => {
24 | return typeof ( icon as SvgIcon )?.svg === 'string';
25 | };
26 |
--------------------------------------------------------------------------------
/src/utils/index.ts:
--------------------------------------------------------------------------------
1 | import { dispatch } from '@wordpress/data';
2 | import { dateI18n } from '@wordpress/date';
3 |
4 | import { WEEK_IN_MILLISECONDS, STORE_NAMESPACE } from '../constants';
5 | import type { Notice } from '../types';
6 |
7 | /**
8 | * Delay returns a promise that resolves after the specified number of milliseconds.
9 | *
10 | * @param ms The number of milliseconds to delay.
11 | *
12 | * @return The resolution of the promise
13 | */
14 | export const delay = ( ms: number ) =>
15 | new Promise( ( f ) => setTimeout( f, ms ) );
16 |
17 | /**
18 | * At the moment the function return the notifications if the split by isn't set to "date"
19 | *
20 | * @param notifications The collection of notices to split.
21 | * @param limit The date after which the notifications are considered to be old
22 | *
23 | * @return Two list of Notifications, one for the new and one for the old
24 | */
25 | export const splitByDate = (
26 | notifications: Notice[],
27 | limit = Date.now() - WEEK_IN_MILLISECONDS
28 | ): [ Notice[], Notice[] ] => {
29 | return notifications.reduce(
30 | ( [ current, past ], notice ) => {
31 | return notice.date.getTime() >= limit
32 | ? [ [ ...current, notice ], past ]
33 | : [ current, [ ...past, notice ] ];
34 | },
35 | [ [], [] ]
36 | );
37 | };
38 |
39 | /**
40 | * Convert a `Date` to an integer of seconds
41 | *
42 | * @param date The date to convert.
43 | *
44 | * @return The integer value of the `date` in seconds.
45 | */
46 | export const dateToSeconds = ( date: Date ) => {
47 | return Math.floor( date.getTime() * 0.001 );
48 | };
49 |
50 | /**
51 | * The current date time in seconds.
52 | *
53 | * @return The integer value of the `date` in seconds.
54 | */
55 | export const nowInSeconds = () => {
56 | return Math.floor( Date.now() * 0.001 );
57 | };
58 |
59 | /**
60 | * Format the date from epoch to human-readable format.
61 | *
62 | * @param date The date to convert in epoch format.
63 | *
64 | * @return The date in human-readable format.
65 | */
66 | export const formatDate = ( date: Date ) => {
67 | return dateI18n( 'l jS F Y - h:i A', date, true );
68 | };
69 |
70 | /**
71 | * It clears the notices in the selected context
72 | *
73 | * @param context - The context of the notices. This is used to determine which notices to clear.
74 | */
75 | export const clearNotificationsDrawer = ( context: string ) => {
76 | dispatch( STORE_NAMESPACE ).clear( context );
77 | };
78 |
--------------------------------------------------------------------------------
/src/utils/init.tsx:
--------------------------------------------------------------------------------
1 | import { createRoot } from '@wordpress/element';
2 |
3 | import NoticesArea from '../components/notice-area';
4 | import NotificationHub from '../components/notification-hub';
5 |
6 | export function addContext( context: string ) {
7 | /** Get the component container */
8 | const notificationContext = document.getElementById(
9 | context === 'adminbar'
10 | ? 'wp-admin-bar-wp-notifications-hub'
11 | : `wp-notifications-${ context }`
12 | );
13 |
14 | /** If the notification context exists, render it */
15 | if ( notificationContext ) {
16 | /** Creates a root for Notification area, whenever is the hub or a defined area like the dashboard */
17 | const componentRoot = createRoot( notificationContext );
18 |
19 | /**
20 | * Renders the component into the specified context
21 | *
22 | * @member {HTMLElement} notificationDash - the area that will host the notifications
23 | */
24 | componentRoot.render(
25 | context === 'adminbar' ? (
26 |
27 | ) : (
28 |
29 | )
30 | );
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/src/utils/sanitization.ts:
--------------------------------------------------------------------------------
1 | import { escapeHTML } from '@wordpress/escape-html';
2 |
3 | /**
4 | * It takes a string and returns a sanitized version of that string.
5 | *
6 | * @param string - The text to be purified.
7 | * @return The sanitized string.
8 | */
9 | export const purify = ( string: string ) => {
10 | return {
11 | __html: escapeHTML( string ) as string,
12 | };
13 | };
14 |
--------------------------------------------------------------------------------
/src/wp-notifications.ts:
--------------------------------------------------------------------------------
1 | /** WordPress Dependencies */
2 | import { dispatch, select } from '@wordpress/data';
3 |
4 | import './styles/wp-notifications.scss';
5 |
6 | /** The store default data */
7 | import { STORE_NAMESPACE } from './constants';
8 | import { contexts } from './store/constants';
9 | import type { Notice } from './types';
10 | import { addContext } from './utils/init';
11 |
12 | /**
13 | * The redux store
14 | */
15 | export * as store from './store';
16 |
17 | /**
18 | * WP Feature Notifications API
19 | */
20 | const notifications = {
21 | /**
22 | * Fetch for new notices
23 | */
24 | fetchUpdates: () => select( STORE_NAMESPACE ).fetchUpdates(),
25 |
26 | /**
27 | * List all notifications or those of a particular context
28 | *
29 | * @param context The display context to retrieve.
30 | */
31 | get: ( context = '' ) => select( STORE_NAMESPACE ).getNotices( context ),
32 |
33 | /**
34 | * Search for a notification by key or term (term is optional, returns an array of objects)
35 | *
36 | * @param term
37 | * @param args
38 | * @example ```js
39 | * // if you need to find a notification by key
40 | * notifications.find(5) // [{ 'id': 5, title: "hello", location: "dashboard", ... }]
41 | * // or by term
42 | * notifications.find("hello", {term: 'title'}) // [{ 'id': 5, title: "hello", location: "dashboard", ... }, {...}]
43 | * ```
44 | */
45 | find: ( term: string, args: any ) =>
46 | select( STORE_NAMESPACE ).findNotice( term, args ),
47 |
48 | /**
49 | * Add a new notification
50 | *
51 | * @param payload
52 | */
53 | add: ( payload: Notice ) =>
54 | dispatch( STORE_NAMESPACE ).addNotice( payload ),
55 |
56 | /**
57 | * Remove a notification by key
58 | *
59 | * @param id The id of the notification to remove.
60 | */
61 | remove: ( id: number ) => dispatch( STORE_NAMESPACE ).removeNotice( id ),
62 |
63 | /**
64 | * Clear all notifications
65 | *
66 | * @param context The display context to clear of notifications.
67 | */
68 | clear: ( context = 'adminbar' ) =>
69 | dispatch( STORE_NAMESPACE ).clear( context ),
70 | };
71 |
72 | /** Appends the wp-notifications instance to window.wp in order to provide a public API */
73 | window.wp.notifications = notifications;
74 |
75 | /**
76 | * Loops into contexts and register the found locations into the store state
77 | */
78 | contexts.forEach( ( context ) =>
79 | select( STORE_NAMESPACE ).registerContext( context )
80 | );
81 |
82 | /** after registering contexts we could fetch the notifications */
83 | select( STORE_NAMESPACE ).fetchUpdates();
84 |
85 | /**
86 | * Loops into contexts and adds a NoticesArea component for each one
87 | */
88 | contexts.forEach( ( context ) => addContext( context ) );
89 |
90 | /**
91 | * exports notifications store functions for further uses
92 | */
93 | export default notifications;
94 |
--------------------------------------------------------------------------------
/storybook/.babelrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [ "@wordpress/babel-preset-default" ]
3 | }
4 |
--------------------------------------------------------------------------------
/storybook/.npmrc:
--------------------------------------------------------------------------------
1 | legacy-peer-deps=true
2 |
--------------------------------------------------------------------------------
/storybook/.storybook/main.js:
--------------------------------------------------------------------------------
1 | // automatically get all the stories from the stories folder
2 | const doc = ['../stories/**/*.@(mdx)'].filter(Boolean);
3 | const stories = ['../stories/**/*.@(js|jsx|ts|tsx)'].filter(Boolean);
4 | module.exports = {
5 | stories: [...doc, ...stories],
6 | addons: [
7 | '@storybook/addon-a11y',
8 | '@storybook/addon-links',
9 | '@storybook/addon-essentials',
10 | '@storybook/addon-interactions',
11 | '@storybook/addon-mdx-gfm'
12 | ],
13 | framework: {
14 | name: '@storybook/react-webpack5',
15 | options: { lazyCompilation: true }
16 | },
17 | features: {
18 | babelModeV7: true,
19 | emotionAlias: false,
20 | storyStoreV7: true,
21 | },
22 | webpackFinal: async (config) => {
23 | // Add the ts loader
24 | config.module.rules.push(
25 | {
26 | test: /\.tsx?$/,
27 | use: [
28 | {
29 | loader: 'babel-loader',
30 | options: {
31 | presets: ["@wordpress/babel-preset-default"],
32 | },
33 | },
34 | ],
35 | exclude: /node_modules/
36 | },
37 | );
38 |
39 | // Add the scss loader
40 | config.module.rules.push({
41 | test: /\.scss$/,
42 | use: ['style-loader', 'css-loader', 'sass-loader'],
43 | });
44 |
45 | return config;
46 | },
47 | docs: {
48 | autodocs: true
49 | }
50 | };
51 |
--------------------------------------------------------------------------------
/storybook/.storybook/manager.js:
--------------------------------------------------------------------------------
1 | /**
2 | * External dependencies
3 | */
4 | import {addons} from '@storybook/addons';
5 |
6 | import wpNotificationsTheme from './wp-notifications-theme';
7 |
8 | addons.setConfig({
9 | theme: wpNotificationsTheme,
10 | });
11 |
--------------------------------------------------------------------------------
/storybook/.storybook/preview.js:
--------------------------------------------------------------------------------
1 | /**
2 | * External dependencies
3 | */
4 |
5 | /**
6 | * WordPress dependencies
7 | */
8 | import '@wordpress/components/build-style/style.css';
9 |
10 | /**
11 | * Internal dependencies
12 | */
13 | /** Backend style */
14 | import '../stories/assets/wp-core/admin-bar.css';
15 | import '../stories/assets/wp-core/admin-menu.css';
16 | import '../stories/assets/wp-core/buttons.css';
17 | import '../stories/assets/wp-core/common.css';
18 | import '../stories/assets/wp-core/dashboard.css';
19 | import '../stories/assets/wp-core/dashicons.css';
20 | import '../stories/assets/wp-core/edit.css';
21 | import '../stories/assets/wp-core/nav-menus.css';
22 | import '../stories/assets/wp-core/normalize.css';
23 | import '../stories/assets/wp-core/site-health.css';
24 |
25 | /** wp-feature-notifications style */
26 | import '../../src/styles/wp-notifications.scss';
27 |
28 | export const parameters = {
29 | actions: { argTypesRegex: "^on[A-Z].*" },
30 | controls: {
31 | matchers: {
32 | color: /(background|color)$/i,
33 | date: /date$/,
34 | },
35 | },
36 | }
37 |
--------------------------------------------------------------------------------
/storybook/.storybook/wp-notifications-theme.js:
--------------------------------------------------------------------------------
1 | import {create} from '@storybook/theming';
2 | import Logo from '../stories/assets/logo.svg';
3 |
4 | export default create({
5 | base: 'light',
6 | brandTitle: 'WP Feature Notifications storybook',
7 | brandUrl: 'https://github.com/WordPress/wp-feature-notifications',
8 | brandImage: Logo,
9 | brandTarget: '_self',
10 | });
11 |
--------------------------------------------------------------------------------
/storybook/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@wordpress/wp-feature-notifications-stories",
3 | "version": "1.0.0",
4 | "main": "stories/Introduction.stories.mdx",
5 | "scripts": {
6 | "start": "storybook dev -p 6006",
7 | "build": "storybook build --output-dir ../docs/"
8 | },
9 | "devDependencies": {
10 | "@storybook/addon-a11y": "^7.6.1",
11 | "@storybook/addon-docs": "^7.6.1",
12 | "@storybook/addon-essentials": "^7.6.1",
13 | "@storybook/addon-interactions": "^7.6.1",
14 | "@storybook/addon-links": "^7.6.1",
15 | "@storybook/addon-mdx-gfm": "^7.6.1",
16 | "@storybook/addons": "^7.6.1",
17 | "@storybook/blocks": "^7.6.1",
18 | "@storybook/source-loader": "^7.6.1",
19 | "@storybook/react": "^7.6.1",
20 | "@storybook/react-webpack5": "^7.6.1",
21 | "@storybook/theming": "^7.6.1",
22 | "@wordpress/wp-feature-notifications": "../",
23 | "storybook": "^7.6.1"
24 | },
25 | "peerDependencies": {
26 | "react": "^18.2.0",
27 | "react-dom": "^18.2.0"
28 | },
29 | "eslintConfig": {
30 | "extends": [
31 | "plugin:@wordpress/eslint-plugin/recommended"
32 | ],
33 | "overrides": [
34 | {
35 | "files": [
36 | "**/*.stories.*"
37 | ],
38 | "rules": {
39 | "import/no-anonymous-default-export": "off"
40 | }
41 | }
42 | ]
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/storybook/stories/Dash-multiple.stories.jsx:
--------------------------------------------------------------------------------
1 | /** wp-feature-notifications style */
2 | import NoticesLoop from '../../src/components/notice-loop';
3 | import jsonData from '../fake_api.json';
4 |
5 | // filter out non dashboard notices
6 | const adminBarNotices = jsonData
7 | .filter( ( notice ) => notice.id && notice.id >= 10 )
8 | .map(
9 | ( notice, index ) =>
10 | ( notice = {
11 | ...notice,
12 | context: 'dashboard',
13 | id: index,
14 | } )
15 | );
16 |
17 | export default {
18 | title: 'wp-feature-notifications/Dashboard/Multiple',
19 | component: NoticesLoop,
20 | parameters: {
21 | backgrounds: {
22 | default: 'WordPress',
23 | values: [ { name: 'WordPress', value: '#f0f0f1' } ],
24 | },
25 | },
26 | };
27 |
28 | /**
29 | * Single Notification UI component
30 | * https://github.com/WordPress/wp-feature-notifications/issues/16#issuecomment-896031592
31 | * https://github.com/WordPress/wp-feature-notifications/issues/37#issuecomment-896080025
32 | */
33 |
34 | // More on component templates: https://storybook.js.org/docs/react/writing-stories/introduction#using-args
35 | const MultipleNotificationsTemplate = () => (
36 | <>
37 |
44 |
45 |
46 | >
47 | );
48 |
49 | // More on args: https://storybook.js.org/docs/react/writing-stories/args
50 | export const Multiple = MultipleNotificationsTemplate;
51 |
--------------------------------------------------------------------------------
/storybook/stories/Dash-single.stories.jsx:
--------------------------------------------------------------------------------
1 | /** the single notification component */
2 | import Notice from '../../src/components/notice';
3 |
4 | export default {
5 | title: 'wp-feature-notifications/Dashboard/Single',
6 | component: Notice,
7 | parameters: {
8 | backgrounds: {
9 | default: 'WordPress',
10 | values: [ { name: 'WordPress', value: '#f0f0f1' } ],
11 | },
12 | },
13 | };
14 |
15 | /**
16 | * Single Notification UI component
17 | * https://github.com/WordPress/wp-feature-notifications/issues/16#issuecomment-896031592
18 | * https://github.com/WordPress/wp-feature-notifications/issues/37#issuecomment-896080025
19 | */
20 |
21 | // More on component templates: https://storybook.js.org/docs/react/writing-stories/introduction#using-args
22 | const Template = ( args ) => (
23 |
34 | );
35 |
36 | export const single = Template.bind( {} );
37 | // More on args: https://storybook.js.org/docs/react/writing-stories/args
38 | single.args = {
39 | id: 15,
40 | title: 'Try this new Notification feature',
41 | source: 'WP Feature Notifications',
42 | date: '2023-04-15T19:35:56',
43 | message:
44 | '👋 Hello from the WP Feature Notifications team! Thank you for testing out the plugin. You might want to give it a try so click on the bell icon on the right side of the adminbar.',
45 | dismissible: true,
46 | };
47 |
--------------------------------------------------------------------------------
/storybook/stories/Hub-empty.stories.jsx:
--------------------------------------------------------------------------------
1 | /** the empty notification component */
2 |
3 | import { dispatch } from '@wordpress/data';
4 | import { __ } from '@wordpress/i18n';
5 |
6 | import NotificationHub from '../../src/components/notification-hub';
7 | import { STORE_NAMESPACE } from '../../src/constants';
8 |
9 | export default {
10 | title: 'wp-feature-notifications/Notification Hub/Empty',
11 | component: NotificationHub,
12 | parameters: {
13 | backgrounds: {
14 | default: 'WordPress',
15 | values: [ { name: 'WordPress', value: '#f0f0f1' } ],
16 | },
17 | },
18 | };
19 |
20 | /**
21 | * Notification UI component
22 | */
23 | const Template = () => {
24 | dispatch( STORE_NAMESPACE ).clear( 'adminbar' );
25 |
26 | return (
27 |
46 | );
47 | };
48 |
49 | export const Empty = Template.bind( {} );
50 | // More on args: https://storybook.js.org/docs/react/writing-stories/args
51 |
52 | Empty.args = {
53 | size: 96,
54 | message: __( 'empty' ),
55 | };
56 |
--------------------------------------------------------------------------------
/storybook/stories/Hub-multiple.stories.jsx:
--------------------------------------------------------------------------------
1 | /** the single notification component */
2 | import { dispatch } from '@wordpress/data';
3 |
4 | import NotificationHub from '../../src/components/notification-hub';
5 | import { STORE_NAMESPACE } from '../../src/constants';
6 | import jsonData from '../fake_api.json';
7 |
8 | export default {
9 | title: 'wp-feature-notifications/Notification Hub/Multiple',
10 | component: NotificationHub,
11 | parameters: {
12 | backgrounds: {
13 | default: 'WordPress',
14 | values: [ { name: 'WordPress', value: '#f0f0f1' } ],
15 | },
16 | },
17 | };
18 |
19 | /**
20 | * Notification HUB component
21 | */
22 | const Template = () => {
23 | dispatch( STORE_NAMESPACE ).clear( 'adminbar' );
24 |
25 | jsonData.forEach( ( notice ) => {
26 | dispatch( STORE_NAMESPACE ).addNotice( {
27 | ...notice,
28 | context: 'adminbar',
29 | date: new Date( notice.date ),
30 | } );
31 | } );
32 |
33 | return (
34 |
53 | );
54 | };
55 |
56 | export const multiple = Template.bind( {} );
57 | // More on args: https://storybook.js.org/docs/react/writing-stories/args
58 |
59 | multiple.args = {};
60 |
--------------------------------------------------------------------------------
/storybook/stories/Hub-single.stories.jsx:
--------------------------------------------------------------------------------
1 | /** the single notification component */
2 |
3 | import { dispatch } from '@wordpress/data';
4 |
5 | import NotificationHub from '../../src/components/notification-hub';
6 | import { STORE_NAMESPACE } from '../../src/constants';
7 |
8 | export default {
9 | title: 'wp-feature-notifications/Notification Hub/Single',
10 | component: NotificationHub,
11 | parameters: {
12 | backgrounds: {
13 | default: 'WordPress',
14 | values: [ { name: 'WordPress', value: '#f0f0f1' } ],
15 | },
16 | },
17 | };
18 |
19 | /**
20 | * Notification UI component
21 | *
22 | * @param {Object} args The notice controls.
23 | */
24 | const Template = ( args ) => {
25 | dispatch( STORE_NAMESPACE ).clear( 'adminbar' );
26 |
27 | dispatch( STORE_NAMESPACE ).addNotice( {
28 | ...args,
29 | context: 'adminbar',
30 | } );
31 |
32 | return (
33 |
52 | );
53 | };
54 |
55 | export const single = Template.bind( {} );
56 | // More on args: https://storybook.js.org/docs/react/writing-stories/args
57 | single.args = {
58 | id: 15,
59 | title: 'Try this new Notification feature',
60 | source: 'WP Feature Notifications',
61 | date: new Date(),
62 | message:
63 | '👋 Hello from the WP Feature Notifications team! Thank you for testing out the plugin. You might want to give it a try so click on the bell icon on the right side of the adminbar.',
64 | dismissible: true,
65 | };
66 |
--------------------------------------------------------------------------------
/storybook/stories/assets/WordPressLogo.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/storybook/stories/assets/code.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/storybook/stories/assets/env.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/storybook/stories/assets/logo.afphoto:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/WordPress/wp-feature-notifications/ac990811d8c8ba4ba090fe99f71839991866b662/storybook/stories/assets/logo.afphoto
--------------------------------------------------------------------------------
/storybook/stories/assets/logo.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/storybook/stories/assets/slack.svg:
--------------------------------------------------------------------------------
1 |
7 |
--------------------------------------------------------------------------------
/storybook/stories/assets/wp-core/fonts/dashicons.eot:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/WordPress/wp-feature-notifications/ac990811d8c8ba4ba090fe99f71839991866b662/storybook/stories/assets/wp-core/fonts/dashicons.eot
--------------------------------------------------------------------------------
/storybook/stories/assets/wp-core/fonts/dashicons.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/WordPress/wp-feature-notifications/ac990811d8c8ba4ba090fe99f71839991866b662/storybook/stories/assets/wp-core/fonts/dashicons.ttf
--------------------------------------------------------------------------------
/storybook/stories/assets/wp-core/fonts/dashicons.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/WordPress/wp-feature-notifications/ac990811d8c8ba4ba090fe99f71839991866b662/storybook/stories/assets/wp-core/fonts/dashicons.woff
--------------------------------------------------------------------------------
/storybook/stories/assets/wp-core/fonts/dashicons.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/WordPress/wp-feature-notifications/ac990811d8c8ba4ba090fe99f71839991866b662/storybook/stories/assets/wp-core/fonts/dashicons.woff2
--------------------------------------------------------------------------------
/storybook/stories/assets/wp-core/images/about-header-about.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/storybook/stories/assets/wp-core/images/about-texture.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/WordPress/wp-feature-notifications/ac990811d8c8ba4ba090fe99f71839991866b662/storybook/stories/assets/wp-core/images/about-texture.png
--------------------------------------------------------------------------------
/storybook/stories/assets/wp-core/images/resize-2x.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/WordPress/wp-feature-notifications/ac990811d8c8ba4ba090fe99f71839991866b662/storybook/stories/assets/wp-core/images/resize-2x.gif
--------------------------------------------------------------------------------
/storybook/stories/assets/wp-core/images/resize-rtl-2x.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/WordPress/wp-feature-notifications/ac990811d8c8ba4ba090fe99f71839991866b662/storybook/stories/assets/wp-core/images/resize-rtl-2x.gif
--------------------------------------------------------------------------------
/storybook/stories/assets/wp-core/images/resize-rtl.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/WordPress/wp-feature-notifications/ac990811d8c8ba4ba090fe99f71839991866b662/storybook/stories/assets/wp-core/images/resize-rtl.gif
--------------------------------------------------------------------------------
/storybook/stories/assets/wp-core/images/resize.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/WordPress/wp-feature-notifications/ac990811d8c8ba4ba090fe99f71839991866b662/storybook/stories/assets/wp-core/images/resize.gif
--------------------------------------------------------------------------------
/storybook/stories/assets/wp-core/images/spinner-2x.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/WordPress/wp-feature-notifications/ac990811d8c8ba4ba090fe99f71839991866b662/storybook/stories/assets/wp-core/images/spinner-2x.gif
--------------------------------------------------------------------------------
/storybook/stories/assets/wp-core/images/spinner.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/WordPress/wp-feature-notifications/ac990811d8c8ba4ba090fe99f71839991866b662/storybook/stories/assets/wp-core/images/spinner.gif
--------------------------------------------------------------------------------
/storybook/stories/assets/wp-core/images/stars-2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/WordPress/wp-feature-notifications/ac990811d8c8ba4ba090fe99f71839991866b662/storybook/stories/assets/wp-core/images/stars-2x.png
--------------------------------------------------------------------------------
/storybook/stories/assets/wp-core/images/stars.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/WordPress/wp-feature-notifications/ac990811d8c8ba4ba090fe99f71839991866b662/storybook/stories/assets/wp-core/images/stars.png
--------------------------------------------------------------------------------
/storybook/stories/docs/contributors/databaseSchema.stories.mdx:
--------------------------------------------------------------------------------
1 | import { Markdown } from '@storybook/blocks';
2 | import Develop from './develop.md?raw';
3 | import { Meta } from '@storybook/addon-docs';
4 |
5 |
6 |
7 | {Develop}
8 |
9 |
--------------------------------------------------------------------------------
/storybook/stories/docs/contributors/develop.md:
--------------------------------------------------------------------------------
1 | # Code contributions
2 |
3 | In order to make any code contributions, you will need to install [Git](https://git-scm.com/) on your computer.
4 |
5 | ## Developing on a local environment
6 |
7 | Any WAMP/MAMP/LAMP local environment with a WordPress installation will be suited for local development.
8 |
9 | ### Running PHP unit tests
10 |
11 | **Warning**: For running tests, you need a **dedicated test database**. This is important to separate it from your production databases because the tests will drop the complete database each time they are run!
12 |
13 | You also will need a local copy of the [WordPress/wordpress-develop](https://github.com/WordPress/wordpress-develop) repository.
14 |
15 | ```bash
16 | # Through HTTPS
17 | git clone https://github.com/WordPress/wordpress-develop.git
18 | # or
19 | # Through SSH
20 | git clone git@github.com:WordPress/wordpress-develop.git
21 | ```
22 |
23 | You will then need to add a `WP_TESTS_DIR` **environment variable** that points to the WordPress tests library.
24 |
25 | For **Unix** operating systems:
26 |
27 | In your `~/.profile` file, add the following line:
28 |
29 | ```bash
30 | export WP_TESTS_DIR="$HOME/path/to/wordpress-develop/tests/phpunit"
31 | ```
32 |
33 | Then in `wordpress-develop`, copy the `wp-tests-config-sample.php` into a `wp-tests-config.php` file.
34 |
35 | Change the following lines to match your test database:
36 |
37 | ```php
38 | define( 'DB_NAME', 'my-dedicated-test-database' );
39 | define( 'DB_USER', 'user' );
40 | define( 'DB_PASSWORD', 'password' );
41 | define( 'DB_HOST', 'localhost' );
42 | ```
43 |
44 | You also need a local installation of [Composer](https://getcomposer.org/doc/00-intro.md). This will let you install the development dependencies, including [PHPUnit](https://phpunit.de/).
45 |
46 | ```bash
47 | composer install
48 | ```
49 |
50 | And you can run the tests from the PHPUnit package:
51 |
52 | ```bash
53 | vendor/bin/phpunit
54 | ```
55 |
56 | ## Developing with wp-env
57 |
58 | The [wp-env package](https://developer.wordpress.org/block-editor/packages/packages-env/) was developed with the Gutenberg project as a quick way to create a standard WordPress environment using Docker. It is also published as the `@wordpress/env` npm package.
59 |
60 | You can use it for contributing to the WP Feature Notifications project, but you need to install it on your computer first. read the [prerequisites](https://developer.wordpress.org/block-editor/packages/packages-env/#prerequisites) and the [install as a global package](https://developer.wordpress.org/block-editor/packages/packages-env/#installation-as-a-global-package) from its manual.
61 |
62 | ### Running PHP unit tests
63 |
64 | An npm script is provided in order to start the PHP unit tests:
65 |
66 | ```bash
67 | npm run test-unit-php
68 | ```
69 |
--------------------------------------------------------------------------------
/storybook/stories/docs/database-schema.md:
--------------------------------------------------------------------------------
1 | # Database Schema
2 |
3 | ## Key features
4 |
5 | - Ability to target individual users and maintain a history of their notifications.
6 | - Organization of notifications by channel.
7 | - Subscriptions and snooze functionality.
8 | - Table structure optimized for search by user or channel. Minimizing repetitive string data.
9 |
10 | ## Message schema
11 |
12 | Message data is related to many users through the `wp_notifications_queue` table.
13 |
14 | Message are translated at the time of emission to the preferences of the users subscribed to the channel. A single notice emission may have multiple translated message row entries.
15 |
16 | ### wp_notifications_messages table
17 |
18 | - `id: BIGINT(30)` - The ID of the notification.
19 |
20 | - `channel_name: VARCHAR(32)` - The scoped channel name the notification was emitted from.
21 |
22 | - `created_at: DATETIME` - The timestamp of when the message was broadcast.
23 |
24 | - `title: TINYTEXT` - The translated title of the notification.
25 |
26 | - `message: TINYTEXT` - The translated message content of the notification.
27 |
28 | - `meta: JSON` - Data that doesn’t have to be queried and may change while development of the notification system.
29 |
30 | - `accept_label: string|null` - The translated accept action label.
31 |
32 | - `accept_link: string|null` - The URL of the accept action.
33 |
34 | - `channel_title: string|null` - The translated, human-readable title of the channel the message was emitted from.
35 |
36 | - `dismiss_label: string|null` - The translated dismiss action label.
37 |
38 | - `icon: string|null` - The icon of the notification message.
39 |
40 | - `is_dismissible boolean|null` - Whether the notification is dismissible.
41 |
42 | Most persisted notifications should be dismissible, otherwise they will not be able to be removed from the notification system by the user. But this is left as a way to maintain a compatible API with the existing notices.
43 |
44 | - `severity: string|null` - The severity of the message, examples: info, warning, alert.
45 |
46 | This should be a predefined list of values.
47 |
48 | ## Message Queue
49 |
50 | Join table to map messages to specific users. Could periodically be cleared depending on message `expires_at` and/or how long ago it was dismissed.
51 |
52 | The full history of messages for every user is retained and could be easily looked up.
53 |
54 | If a message has been orphan it can safely be deleted.
55 |
56 | ### wp_notifications_queue table
57 |
58 | - `message_id: BIGINT(20)` - The ID of the translated message related to the notice.
59 |
60 | - `user_id: BIGINT(20)` - The ID of the user the notice belongs to.
61 |
62 | - `channel_name: VARCHAR(64)` - The namespaced channel name the notice was emitted from.
63 |
64 | - `context: VARCHAR(64)` - The display context of the notice.
65 |
66 | - `created_at: DATETIME|NULL` - The datetime of when the notice was create.
67 |
68 | - `dismissed_at: DATETIME|NULL` - The datetime of when the notice was dismissed.
69 |
70 | - `displayed_at: DATETIME|NULL` - The datetime of when the notice was first displayed.
71 |
72 | - `expires_at: DATETIME|NULL` - The optional datetime of when the message expires.
73 |
74 | Allowing notice emitters to specify when a notice expires would help signal when it is appropriate to automatically dispose of a notice. It should be best practice to provide `expires_at` for any notice that isn't high priority.
75 |
76 | ## Subscriptions
77 |
78 | Table used to determine for whom to enqueue messages when emitting a notification from a channel.
79 |
80 | Logic to authorize a users to subscribe to a channel would be based on a comparison of the `role` property of the channel and user.
81 |
82 | Notifications can become overwhelming if the user isn't provided with options to snooze and/or unsubscribe from channels.
83 |
84 | ### wp_notifications_subscriptions
85 |
86 | - `user_id: BIGINT(20)` - The ID of the user the subscription belongs to.
87 |
88 | - `channel_name: VARCHAR(65)` - The scoped name of the channel subscribed to.
89 |
90 | - `created_at: DATETIME|NULL` - The datetime of when the subscription was create.
91 |
92 | - `snoozed_until: DATETIME|NULL` - The optional timestamp of when to resume the channel.
93 |
94 | ## Metadata
95 |
96 | The `meta` field of the message and channel tables could be stored in another table, similar to other WordPress schemas. Though keeping it in the same table reduces the number of queries.
97 |
98 | ## Channels
99 |
100 | The concept of channels is similar to block types in the editor. They are registered in code by plugins and the scoped name is used for channel discovery through the `Channel_Registry`. See PR [#251](https://github.com/WordPress/wp-feature-notifications/pull/251) for details about the channel registry.
101 |
--------------------------------------------------------------------------------
/storybook/stories/docs/databaseSchema.stories.mdx:
--------------------------------------------------------------------------------
1 | import { Meta } from '@storybook/addon-docs';
2 | import { Markdown } from '@storybook/blocks';
3 |
4 | import DatabaseSchema from './database-schema.md?raw';
5 |
6 |
7 |
8 | { DatabaseSchema }
9 |
--------------------------------------------------------------------------------
/storybook/stories/docs/internal-api.md:
--------------------------------------------------------------------------------
1 | # Internal API Reference
2 |
3 | `wp_notify( $recipients, $message )`
4 |
5 | `$recipients` can be a user ID, a role string, an array of user IDs or role strings, or an instance of an object implementing the `WP_Notification_Recipient` interface.
6 |
7 | `$message` can be string or an instance of an object implementing the `WP_Notification_Message` interface.
8 |
9 |
--------------------------------------------------------------------------------
/storybook/stories/docs/internalApi.stories.mdx:
--------------------------------------------------------------------------------
1 | import { Meta } from '@storybook/addon-docs';
2 | import { Markdown } from '@storybook/blocks';
3 |
4 | import InternalApi from './internal-api.md?raw';
5 |
6 |
7 |
8 | { InternalApi }
9 |
--------------------------------------------------------------------------------
/storybook/stories/docs/rest-api.md:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/WordPress/wp-feature-notifications/ac990811d8c8ba4ba090fe99f71839991866b662/storybook/stories/docs/rest-api.md
--------------------------------------------------------------------------------
/storybook/stories/docs/restApi.stories.mdx:
--------------------------------------------------------------------------------
1 | import { Meta } from '@storybook/addon-docs';
2 | import { Markdown } from '@storybook/blocks';
3 |
4 | import RestApi from './rest-api.md?raw';
5 |
6 |
7 |
8 | { RestApi }
9 |
--------------------------------------------------------------------------------
/storybook/stories/docs/translations.md:
--------------------------------------------------------------------------------
1 | # Translations
2 |
3 | See also Database Schema
4 |
5 | To allow for internationalisation, the title and message text for a single notification is purposefully not stored in the database.
6 |
7 | Instead, a plugin or theme should store a key, which is linked to a translatable message in the plugin or theme's translation files.
8 |
9 | For example, given the following list of translatable strings in a plugin
10 |
11 | ```
12 | $notifications = array(
13 | 'plugin_slug_new_podcast_title' => __('Some title', 'plugin-slug'),
14 | 'plugin_slug_message' => __('Some message', 'plugin-slug'),
15 | );
16 | ```
17 |
18 | When adding a notification to the wp_notifications table, the values will be stored as follows
19 |
20 | ```
21 | title_key = 'plugin_slug_new_podcast_title'
22 | message_key = 'plugin_slug_message'
23 | ```
24 |
25 | The plugin will then be responsible for looking up these strings, and returning the translated version.
--------------------------------------------------------------------------------
/storybook/stories/docs/translations.stories.mdx:
--------------------------------------------------------------------------------
1 | import { Meta } from '@storybook/addon-docs';
2 | import { Markdown } from '@storybook/blocks';
3 |
4 | import Translations from './translations.md?raw';
5 |
6 |
7 |
8 | { Translations }
9 |
--------------------------------------------------------------------------------
/storybook/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "..",
3 | "compilerOptions": {
4 | "module": "commonjs",
5 | "target": "es5",
6 | "lib": [ "dom", "dom.iterable", "esnext" ]
7 | },
8 | "include": [ "stories" ],
9 | "exclude": [ "node_modules" ]
10 | }
11 |
--------------------------------------------------------------------------------
/tests/jse2e/main.test.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Load utilities from the e2e-test-utils package.
3 | */
4 | import { visitAdminPage } from '@wordpress/e2e-test-utils';
5 |
6 | // Name of the test suite.
7 | describe( 'Hello World', () => {
8 | // Flow being tested.
9 | // Ideally each flow is independent and can be run separately.
10 | it( 'Should load properly', async () => {
11 | // Navigate the admin and performs tasks
12 | // Use Puppeteer APIs to interact with mouse, keyboard...
13 | await visitAdminPage( '/' );
14 |
15 | // Assertions
16 | const nodes = await page.$x(
17 | '//h2[contains(text(), "Welcome to WordPress!")]'
18 | );
19 | expect( nodes.length ).not.toEqual( 0 );
20 | }, 10000 );
21 | } );
22 |
--------------------------------------------------------------------------------
/tests/jsunit/main.test.js:
--------------------------------------------------------------------------------
1 | /* JEST unit testing */
2 | describe( 'boilerplate test', () => {
3 | it( 'it can make simple additions', () => {
4 | expect( 1 + 2 ).toBe( 3 );
5 | } );
6 | } );
7 |
--------------------------------------------------------------------------------
/tests/phpunit/includes/bootstrap.php:
--------------------------------------------------------------------------------
1 | get_error_message();
25 | }
26 |
27 | throw new Exception( 'WordPress died: ' . $message );
28 | }
29 | tests_add_filter( 'wp_die_handler', 'handle_wp_setup_failure' );
30 |
31 | /*
32 | * Load PHPUnit Polyfills for the WP testing suite.
33 | */
34 | define( 'WP_TESTS_PHPUNIT_POLYFILLS_PATH', __DIR__ . '/../../../vendor/yoast/phpunit-polyfills/phpunitpolyfills-autoload.php' );
35 |
36 | // load the WP testing environment.
37 | require $tests_dir . '/includes/bootstrap.php';
38 |
39 | remove_filter( 'wp_die_handler', 'handle_wp_setup_failure' );
40 |
41 | require dirname( __FILE__ ) . '/class-testcase.php';
42 | require dirname( __FILE__ ) . '/class-db-testcase.php';
43 |
--------------------------------------------------------------------------------
/tests/phpunit/includes/class-db-testcase.php:
--------------------------------------------------------------------------------
1 | prefix . $table_name;
20 |
21 | $actual = $wpdb->get_var(
22 | $wpdb->prepare(
23 | 'SHOW TABLES LIKE %s',
24 | $wpdb->esc_like( $wpdb->prefix . $table_name )
25 | )
26 | );
27 |
28 | return $expected === $actual;
29 |
30 | }
31 |
32 | }
33 |
--------------------------------------------------------------------------------
/tests/phpunit/includes/class-testcase.php:
--------------------------------------------------------------------------------
1 | assertIsArray( $expected, $message . ' Expected value must be an array.' );
20 | $this->assertIsArray( $actual, $message . ' Value under test is not an array.' );
21 |
22 | sort( $expected );
23 | sort( $actual );
24 | $this->assertSame( $expected, $actual, $message );
25 | }
26 |
27 | }
28 |
--------------------------------------------------------------------------------
/tests/phpunit/tests/factory/test-factory-message.php:
--------------------------------------------------------------------------------
1 | factory = new Factory\Message();
21 | }
22 |
23 | /**
24 | * Tear down each test method.
25 | */
26 | public function tear_down() {
27 | $this->factory = null;
28 |
29 | parent::tear_down();
30 | }
31 |
32 | /**
33 | * Should create an instance of the message model class.
34 | */
35 | public function test_makes_message_instance() {
36 | $actual = $this->factory->make();
37 | $this->assertInstanceOf( '\WP\Notifications\Model\Message', $actual );
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/tests/phpunit/tests/factory/test-factory-notification.php:
--------------------------------------------------------------------------------
1 | factory = new Factory\Notification();
21 | }
22 |
23 | /**
24 | * Tear down each test method.
25 | */
26 | public function tear_down() {
27 | $this->factory = null;
28 |
29 | parent::tear_down();
30 | }
31 |
32 | /**
33 | * Should create an instance of the notification model class.
34 | */
35 | public function test_makes_notification_instance() {
36 | $actual = $this->factory->make();
37 | $this->assertInstanceOf( '\WP\Notifications\Model\Notification', $actual );
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/tests/phpunit/tests/factory/test-factory-subscription.php:
--------------------------------------------------------------------------------
1 | factory = new Factory\Subscription();
21 | }
22 |
23 | /**
24 | * Tear down each test method.
25 | */
26 | public function tear_down() {
27 | $this->factory = null;
28 |
29 | parent::tear_down();
30 | }
31 |
32 | /**
33 | * Should create an instance of the subscription model class.
34 | */
35 | public function test_makes_subscription_instance() {
36 | $actual = $this->factory->make();
37 | $this->assertInstanceOf( '\WP\Notifications\Model\Subscription', $actual );
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/tests/phpunit/tests/model/test-model-channel.php:
--------------------------------------------------------------------------------
1 | model = new Model\Channel(
21 | 'core/test',
22 | 'Test channel',
23 | 'test',
24 | 'A test case channel',
25 | 'WordPress'
26 | );
27 | }
28 |
29 | /**
30 | * Tear down each test method.
31 | */
32 | public function tear_down() {
33 | $this->model = null;
34 |
35 | parent::tear_down();
36 | }
37 |
38 | /**
39 | * Should be JSON serializable.
40 | */
41 | public function test_json_serializable() {
42 | $actual = json_encode( $this->model, JSON_PRETTY_PRINT );
43 | $expected = '{
44 | "context": "test",
45 | "description": "A test case channel",
46 | "icon": "WordPress",
47 | "name": "core\/test",
48 | "title": "Test channel"
49 | }';
50 | $this->assertEquals( $actual, $expected );
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/tests/phpunit/tests/model/test-model-message.php:
--------------------------------------------------------------------------------
1 | model = new Model\Message(
21 | 'Testing, testings... 1, 2, 3... testing',
22 | 'Ok',
23 | null,
24 | 'Test channel',
25 | null,
26 | 'Nope',
27 | null,
28 | 'hammer',
29 | null,
30 | true,
31 | 'warning',
32 | 'Message model test'
33 | );
34 | }
35 |
36 | /**
37 | * Tear down each test method.
38 | */
39 | public function tear_down() {
40 | $this->model = null;
41 |
42 | parent::tear_down();
43 | }
44 |
45 | /**
46 | * Should be JSON serializable.
47 | */
48 | public function test_json_serializable() {
49 | $actual = json_encode( $this->model, JSON_PRETTY_PRINT );
50 | $expected = '{
51 | "accept_label": "Ok",
52 | "channel_title": "Test channel",
53 | "dismiss_label": "Nope",
54 | "icon": "hammer",
55 | "is_dismissible": true,
56 | "severity": "warning",
57 | "created_at": null,
58 | "expires_at": null,
59 | "id": null,
60 | "message": "Testing, testings... 1, 2, 3... testing",
61 | "title": "Message model test"
62 | }';
63 | $this->assertEquals( $actual, $expected );
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/tests/phpunit/tests/model/test-model-notification.php:
--------------------------------------------------------------------------------
1 | model = new Model\Notification(
22 | 'core/test',
23 | 1,
24 | 1,
25 | 'adminbar',
26 | null,
27 | null,
28 | null,
29 | new DateTime( '2023-12-31' )
30 | );
31 | }
32 |
33 | /**
34 | * Tear down each test method.
35 | */
36 | public function tear_down() {
37 | $this->model = null;
38 |
39 | parent::tear_down();
40 | }
41 |
42 | /**
43 | * Should be JSON serializable.
44 | */
45 | public function test_json_serializable() {
46 | $actual = json_encode( $this->model, JSON_PRETTY_PRINT );
47 | $expected = '{
48 | "channel_name": "core\/test",
49 | "context": "adminbar",
50 | "created_at": null,
51 | "dismissed_at": null,
52 | "displayed_at": null,
53 | "expires_at": "2023-12-31T00:00:00+00:00",
54 | "message_id": 1,
55 | "user_id": 1
56 | }';
57 | $this->assertEquals( $actual, $expected );
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/tests/phpunit/tests/model/test-model-subscription.php:
--------------------------------------------------------------------------------
1 | model = new Model\Subscription(
22 | 'core/test',
23 | 1,
24 | null,
25 | new DateTime( '2023-12-31' )
26 | );
27 | }
28 |
29 | /**
30 | * Tear down each test method.
31 | */
32 | public function tear_down() {
33 | $this->model = null;
34 |
35 | parent::tear_down();
36 | }
37 |
38 | /**
39 | * Should be JSON serializable.
40 | */
41 | public function test_json_serializable() {
42 | $actual = json_encode( $this->model, JSON_PRETTY_PRINT );
43 | $expected = '{
44 | "channel_name": "core\/test",
45 | "created_at": null,
46 | "snoozed_until": "2023-12-31T00:00:00+00:00",
47 | "user_id": 1
48 | }';
49 | $this->assertEquals( $actual, $expected );
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/tests/phpunit/tests/rest-api/test-channel-controller.php:
--------------------------------------------------------------------------------
1 | markTestSkipped( 'TODO Implement' );
8 | }
9 |
10 | public function test_context_param() {
11 | $this->markTestSkipped( 'TODO Implement' );
12 | }
13 |
14 | public function test_get_items() {
15 | $this->markTestSkipped( 'TODO Implement' );
16 | }
17 |
18 | public function test_get_item() {
19 | $this->markTestSkipped( 'TODO Implement' );
20 | }
21 |
22 | public function test_create_item() {
23 | $this->markTestSkipped( 'TODO Implement' );
24 | }
25 |
26 | public function test_update_item() {
27 | $this->markTestSkipped( 'TODO Implement' );
28 | }
29 |
30 | public function test_delete_item() {
31 | $this->markTestSkipped( 'TODO Implement' );
32 | }
33 |
34 | public function test_prepare_item() {
35 | $this->markTestSkipped( 'TODO Implement' );
36 | }
37 |
38 | public function test_registered_query_params() {
39 | $request = new WP_REST_Request( 'OPTIONS', '/wp-notifications/v1/channels' );
40 | $response = rest_get_server()->dispatch( $request );
41 | $data = $response->get_data();
42 | $keys = array_keys( $data['endpoints'][0]['args'] );
43 | sort( $keys );
44 | $this->assertSame(
45 | array(
46 | 'context',
47 | 'offset',
48 | 'page',
49 | 'per_page',
50 | 'search',
51 | ),
52 | $keys
53 | );
54 | }
55 |
56 | public function test_get_item_schema() {
57 | $request = new WP_REST_Request( 'OPTIONS', '/wp-notifications/v1/channels' );
58 | $response = rest_get_server()->dispatch( $request );
59 | $data = $response->get_data();
60 | $properties = $data['schema']['properties'];
61 | $this->assertCount( 4, $properties );
62 | $this->assertArrayHasKey( 'context', $properties );
63 | $this->assertArrayHasKey( 'icon', $properties );
64 | $this->assertArrayHasKey( 'name', $properties );
65 | $this->assertArrayHasKey( 'title', $properties );
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/tests/phpunit/tests/rest-api/test-notification-controller.php:
--------------------------------------------------------------------------------
1 | markTestSkipped( 'TODO Implement' );
8 | }
9 |
10 | public function test_context_param() {
11 | $this->markTestSkipped( 'TODO Implement' );
12 | }
13 |
14 | public function test_get_items() {
15 | $this->markTestSkipped( 'TODO Implement' );
16 | }
17 |
18 | public function test_get_item() {
19 | $this->markTestSkipped( 'TODO Implement' );
20 | }
21 |
22 | public function test_create_item() {
23 | $this->markTestSkipped( 'TODO Implement' );
24 | }
25 |
26 | public function test_update_item() {
27 | $this->markTestSkipped( 'TODO Implement' );
28 | }
29 |
30 | public function test_delete_item() {
31 | $this->markTestSkipped( 'TODO Implement' );
32 | }
33 |
34 | public function test_prepare_item() {
35 | $this->markTestSkipped( 'TODO Implement' );
36 | }
37 |
38 | public function test_registered_query_params() {
39 | $request = new WP_REST_Request( 'OPTIONS', '/wp-notifications/v1/notifications' );
40 | $response = rest_get_server()->dispatch( $request );
41 | $data = $response->get_data();
42 | $keys = array_keys( $data['endpoints'][0]['args'] );
43 | sort( $keys );
44 | $this->assertSame(
45 | array(
46 | 'channel',
47 | 'context',
48 | 'offset',
49 | 'order',
50 | 'orderby',
51 | 'page',
52 | 'per_page',
53 | 'search',
54 | 'status',
55 | ),
56 | $keys
57 | );
58 | }
59 |
60 | public function test_get_item_schema() {
61 | $request = new WP_REST_Request( 'OPTIONS', '/wp-notifications/v1/notifications' );
62 | $response = rest_get_server()->dispatch( $request );
63 | $data = $response->get_data();
64 | $properties = $data['schema']['properties'];
65 | $this->assertCount( 18, $properties );
66 | $this->assertArrayHasKey( 'accept_label', $properties );
67 | $this->assertArrayHasKey( 'accept_link', $properties );
68 | $this->assertArrayHasKey( 'channel_name', $properties );
69 | $this->assertArrayHasKey( 'channel_title', $properties );
70 | $this->assertArrayHasKey( 'context', $properties );
71 | $this->assertArrayHasKey( 'created_at', $properties );
72 | $this->assertArrayHasKey( 'dismiss_label', $properties );
73 | $this->assertArrayHasKey( 'dismissed_at', $properties );
74 | $this->assertArrayHasKey( 'displayed_at', $properties );
75 | $this->assertArrayHasKey( 'expires_at', $properties );
76 | $this->assertArrayHasKey( 'icon', $properties );
77 | $this->assertArrayHasKey( 'id', $properties );
78 | $this->assertArrayHasKey( 'is_dismissible', $properties );
79 | $this->assertArrayHasKey( 'message', $properties );
80 | $this->assertArrayHasKey( 'severity', $properties );
81 | $this->assertArrayHasKey( 'status', $properties );
82 | $this->assertArrayHasKey( 'title', $properties );
83 | $this->assertArrayHasKey( 'user_id', $properties );
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/tests/phpunit/tests/rest-api/test-subscription-controller.php:
--------------------------------------------------------------------------------
1 | markTestSkipped( 'TODO Implement' );
8 | }
9 |
10 | public function test_context_param() {
11 | $this->markTestSkipped( 'TODO Implement' );
12 | }
13 |
14 | public function test_get_items() {
15 | $this->markTestSkipped( 'TODO Implement' );
16 | }
17 |
18 | public function test_get_item() {
19 | $this->markTestSkipped( 'TODO Implement' );
20 | }
21 |
22 | public function test_create_item() {
23 | $this->markTestSkipped( 'TODO Implement' );
24 | }
25 |
26 | public function test_update_item() {
27 | $this->markTestSkipped( 'TODO Implement' );
28 | }
29 |
30 | public function test_delete_item() {
31 | $this->markTestSkipped( 'TODO Implement' );
32 | }
33 |
34 | public function test_prepare_item() {
35 | $this->markTestSkipped( 'TODO Implement' );
36 | }
37 |
38 | public function test_registered_query_params() {
39 | $request = new WP_REST_Request( 'OPTIONS', '/wp-notifications/v1/subscriptions' );
40 | $response = rest_get_server()->dispatch( $request );
41 | $data = $response->get_data();
42 | $keys = array_keys( $data['endpoints'][0]['args'] );
43 | sort( $keys );
44 | $this->assertSame(
45 | array(
46 | 'context',
47 | 'offset',
48 | 'order',
49 | 'orderby',
50 | 'page',
51 | 'per_page',
52 | 'search',
53 | ),
54 | $keys
55 | );
56 | }
57 |
58 | public function test_get_item_schema() {
59 | $request = new WP_REST_Request( 'OPTIONS', '/wp-notifications/v1/subscriptions' );
60 | $response = rest_get_server()->dispatch( $request );
61 | $data = $response->get_data();
62 | $properties = $data['schema']['properties'];
63 | $this->assertCount( 4, $properties );
64 | $this->assertArrayHasKey( 'channel_name', $properties );
65 | $this->assertArrayHasKey( 'created_at', $properties );
66 | $this->assertArrayHasKey( 'snoozed_until', $properties );
67 | $this->assertArrayHasKey( 'user_id', $properties );
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/tests/phpunit/tests/test-activator.php:
--------------------------------------------------------------------------------
1 | assertFalse( $this->table_exists( 'notifications_messages' ) );
23 | }
24 |
25 | public function test_it_should_initially_not_have_subscriptions_table() {
26 | $this->assertFalse( $this->table_exists( 'notifications_subscriptions' ) );
27 | }
28 |
29 | public function test_it_should_initially_not_have_queue_table() {
30 | $this->assertFalse( $this->table_exists( 'notifications_queue' ) );
31 | }
32 |
33 | // Installation procedure tests.
34 |
35 | /**
36 | * Test to ensure the uninstall procedure drops the correct tables.
37 | */
38 | public function test_it_should_create_tables() {
39 | Notifications\Activator::create_tables_v1();
40 |
41 | $this->assertTrue( $this->table_exists( 'notifications_messages' ) );
42 | $this->assertTrue( $this->table_exists( 'notifications_subscriptions' ) );
43 | $this->assertTrue( $this->table_exists( 'notifications_queue' ) );
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/tests/phpunit/tests/test-base-image.php:
--------------------------------------------------------------------------------
1 | assertEquals( $expected_json, $encoded_image );
24 | }
25 |
26 | /**
27 | * Tests if an Image can be instantiated from JSON string
28 | *
29 | * @param array $image_data indexed array with [source,alt] elements
30 | * @param string $json
31 | *
32 | * @dataProvider data_provider_images
33 | */
34 | public function test_it_can_be_instantiated_from_json( $image_data, $json ) {
35 |
36 | $test_instance = Image\Base_Image::json_unserialize( $json );
37 |
38 | list( $source, $alt ) = array_values( $image_data );
39 |
40 | $instance = new Image\Base_Image( $source, $alt );
41 |
42 | $this->assertEquals( $instance->get_source(), $test_instance->get_source() );
43 | $this->assertEquals( $instance->get_alt(), $test_instance->get_alt() );
44 | }
45 |
46 | public function data_provider_images() {
47 |
48 | return array(
49 |
50 | 'Image with source and alternative text' => array(
51 | array(
52 | 'source' => 'img-source1',
53 | 'alt' => 'img-alt1',
54 | ),
55 | '{"source":"img-source1","alt":"img-alt1"}',
56 | ),
57 |
58 | 'Image with source and empty string alternative text' => array(
59 | array(
60 | 'source' => 'img-source2',
61 | 'alt' => '',
62 | ),
63 | '{"source":"img-source2"}',
64 | ),
65 |
66 | 'Image with source and null alternative text' => array(
67 | array(
68 | 'source' => 'img-source3',
69 | 'alt' => null,
70 | ),
71 | '{"source":"img-source3"}',
72 | ),
73 |
74 | 'Image with empty string source and alternative text' => array(
75 | array(
76 | 'source' => '',
77 | 'alt' => '',
78 | ),
79 | '[]',
80 | ),
81 | );
82 | }
83 | }
84 |
--------------------------------------------------------------------------------
/tests/phpunit/tests/test-channel-registry.php:
--------------------------------------------------------------------------------
1 | registry = new Notifications\Channel_Registry();
24 | }
25 |
26 | /**
27 | * Tear down each test method.
28 | */
29 | public function tear_down() {
30 | $this->registry = null;
31 |
32 | parent::tear_down();
33 | }
34 |
35 | /**
36 | * Should reject channel without a namespace.
37 | *
38 | * @expectedIncorrectUsage WP\Notifications\Channel_Registry::register
39 | */
40 | public function test_invalid_names_without_namespace() {
41 | $result = $this->registry->register( 'test', array() );
42 | $this->assertFalse( $result );
43 | }
44 |
45 | /**
46 | * Should reject channels with invalid characters.
47 | *
48 | * @expectedIncorrectUsage WP\Notifications\Channel_Registry::register
49 | */
50 | public function test_invalid_characters() {
51 | $result = $this->registry->register( 'test/_doing_it_wrong', array() );
52 | $this->assertFalse( $result );
53 | }
54 |
55 | /**
56 | * Should reject channels with uppercase characters.
57 | *
58 | * @expectedIncorrectUsage WP\Notifications\Channel_Registry::register
59 | */
60 | public function test_uppercase_characters() {
61 | $result = $this->registry->register( 'Core/Test', array() );
62 | $this->assertFalse( $result );
63 | }
64 |
65 | /**
66 | * Should accept valid channel names
67 | */
68 | public function test_register_channel() {
69 | $name = 'core/test';
70 | $settings = array(
71 | 'title' => 'Test Channel',
72 | );
73 |
74 | $channel = $this->registry->register( $name, $settings );
75 | $this->assertSame( $name, $channel->get_name() );
76 | $this->assertSame( $settings['title'], $channel->get_title() );
77 | $this->assertSame( $channel, $this->registry->get_registered( $name ) );
78 | }
79 |
80 | /**
81 | * Should fail to re-register the same channel.
82 | *
83 | * @expectedIncorrectUsage WP\Notifications\Channel_Registry::register
84 | */
85 | public function test_register_channel_twice() {
86 | $name = 'core/test';
87 | $settings = array(
88 | 'title' => 'Test Channel',
89 | );
90 |
91 | $result = $this->registry->register( $name, $settings );
92 | $this->assertNotFalse( $result );
93 | $result = $this->registry->register( $name, $settings );
94 | $this->assertFalse( $result );
95 | }
96 |
97 | /**
98 | * Should accept a Channel instance.
99 | */
100 | public function test_register_channel_instance() {
101 | $channel = new Model\Channel( 'core/test', 'Test Channel' );
102 |
103 | $result = $this->registry->register( $channel );
104 | $this->assertSame( $channel, $result );
105 | }
106 |
107 | /**
108 | * Unregistering should fail if a channel is not registered.
109 | *
110 | * @expectedIncorrectUsage WP\Notifications\Channel_Registry::unregister
111 | */
112 | public function test_unregister_not_registered_channel() {
113 | $result = $this->registry->unregister( 'core/unregistered' );
114 | $this->assertFalse( $result );
115 | }
116 |
117 | /**
118 | * Should unregister existing channels.
119 | */
120 | public function test_unregister_channel() {
121 | $name = 'core/test';
122 | $settings = array(
123 | 'title' => 'Test Channel',
124 | );
125 |
126 | $this->registry->register( $name, $settings );
127 | $channel = $this->registry->unregister( $name );
128 | $this->assertSame( $name, $channel->get_name() );
129 | $this->assertSame( $settings['title'], $channel->get_title() );
130 | $this->assertFalse( $this->registry->is_registered( $name ) );
131 | }
132 |
133 | /**
134 | * Should return all registered channels.
135 | */
136 | public function test_get_all_registered() {
137 | $names = array( 'core/updates', 'core/post-edit', 'core/post-delete' );
138 | $settings = array(
139 | 'title' => 'random',
140 | );
141 |
142 | foreach ( $names as $name ) {
143 | $this->registry->register( $name, $settings );
144 | }
145 |
146 | $registered = $this->registry->get_all_registered();
147 | $this->assertSameSets( $names, array_keys( $registered ) );
148 | }
149 | }
150 |
--------------------------------------------------------------------------------
/tests/phpunit/tests/test-uninstaller.php:
--------------------------------------------------------------------------------
1 | assertTrue( $this->table_exists( 'notifications_messages' ) );
23 | }
24 |
25 | public function test_it_should_initially_have_subscriptions_table() {
26 | $this->assertTrue( $this->table_exists( 'notifications_subscriptions' ) );
27 | }
28 |
29 | public function test_it_should_initially_have_queue_table() {
30 | $this->assertTrue( $this->table_exists( 'notifications_queue' ) );
31 | }
32 |
33 | /**
34 | * Test to ensure the uninstall procedure drops the correct tables.
35 | */
36 | public function test_it_should_drops_tables() {
37 | Notifications\Uninstaller::uninstall();
38 |
39 | $this->assertFalse( $this->table_exists( 'notifications_messages' ) );
40 | $this->assertFalse( $this->table_exists( 'notifications_subscriptions' ) );
41 | $this->assertFalse( $this->table_exists( 'notifications_queue' ) );
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/tsconfig.eslint.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "noEmit": true,
4 | "allowJs": true
5 | },
6 | "include": [
7 | "tests/jse2e/**/*",
8 | "tests/jsunit/**/*",
9 | ".eslintrc.js",
10 | ".prettierrc.js",
11 | "babel.config.js",
12 | "jest.config.js",
13 | "webpack.config.js"
14 | ]
15 | }
16 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "lib": [ "DOM", "DOM.Iterable", "ES2019" ],
4 | "target": "ES2017",
5 | "module": "ES2015",
6 | "moduleResolution": "node",
7 | "allowSyntheticDefaultImports": true,
8 |
9 | "allowJs": true,
10 | "checkJs": true,
11 |
12 | "jsx": "react-jsx",
13 | "jsxImportSource": "react",
14 |
15 | "types": [ "./types/global.d.ts", "jest" ],
16 | "baseUrl": "./src",
17 |
18 | "noEmit": true
19 | },
20 | "include": [ "./src/**/*.ts", "./src/**/*.tsx" ],
21 | "exclude": [ "./node_modules" ]
22 | }
23 |
--------------------------------------------------------------------------------
/types/global.d.ts:
--------------------------------------------------------------------------------
1 | import {CurriedSelectorsOf} from '@wordpress/data/build-types/types'
2 | import {default as store, NoticeStore} from '../src/store'
3 | import {STORE_NAMESPACE} from '../src/constants';
4 |
5 | declare global {
6 | interface Window { wp: { notifications: any }; wp_notifications_data?: { settingsPage: string } }
7 | }
8 |
9 | declare module '@wordpress/data' {
10 | function dispatch( key: typeof store | typeof STORE_NAMESPACE ): typeof import( '../src/store/actions' );
11 | function select( key: typeof store | typeof STORE_NAMESPACE ): CurriedSelectorsOf< NoticeStore >;
12 | }
13 |
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | const path = require( 'path' );
2 |
3 | const defaultConfig = require( '@wordpress/scripts/config/webpack.config' );
4 |
5 | module.exports = {
6 | ...defaultConfig,
7 | entry: {
8 | 'wp-notifications': path.resolve(
9 | process.cwd(),
10 | `src/wp-notifications`
11 | ),
12 | },
13 | };
14 |
--------------------------------------------------------------------------------
/wp-feature-notifications.php:
--------------------------------------------------------------------------------
1 |