diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d22ab73..66ec156 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,14 +16,28 @@ jobs: - name: 'Setup Chrome' uses: browser-actions/setup-chrome@latest - - name: 'Install and Build' + - name: 'Install Dependencies' + run: yarn + + - name: 'Type Check' + run: yarn types + + - name: 'Lint' + run: yarn lint + + - name: 'Build Main Package' + run: yarn build + + - name: 'Build Example - Vite TypeScript DQL' run: | - yarn cd examples/vite-typescript-example yarn - cd - - yarn types - yarn lint + yarn build + + - name: 'Build Example - Vite TypeScript Legacy' + run: | + cd examples/vite-typescript-example-legacy + yarn yarn build - name: 'Run Tests' diff --git a/README.md b/README.md index af8269c..91d5d5e 100644 --- a/README.md +++ b/README.md @@ -253,28 +253,40 @@ root.render( ) ``` -3. In your `App` component, you can now use hooks like `usePendingCursorOperation` or `usePendingIDSpecificOperation` to get your documents like so: +3. In your `App` component, you can now use the DQL hooks `useQuery` and `useExecuteQuery` to interact with your documents: ```tsx -import { usePendingCursorOperation, useMutations } from '@dittolive/react-ditto' +import { useQuery, useExecuteQuery } from '@dittolive/react-ditto' + +interface Task { + _id: string + text: string + isCompleted: boolean +} export default function App() { - const { documents } = usePendingCursorOperation({ - collection: 'tasks', - }) + // Query documents using DQL + const { documents } = useQuery('SELECT * FROM tasks WHERE isCompleted = false') + + // Execute DQL mutations + const [upsert] = useExecuteQuery }>( + 'INSERT INTO tasks DOCUMENTS (:value) ON ID CONFLICT DO UPDATE' + ) - const { removeByID, upsert } = useMutations({ collection: 'tasks' }) + const [removeByID] = useExecuteQuery( + 'UPDATE tasks SET isDeleted = true WHERE _id = :id' + ) return ( <> -
    {documents.map((doc) => ( -
  • - {JSON.stringify(doc.value)} - +
  • + {doc.value.text} +
  • ))}
@@ -283,28 +295,53 @@ export default function App() { } ``` -Alternatively, you can also choose to go with the lazy variants of these hooks (`useLazyPendingCursorOperation` and `useLazyPendingIDSpecificOperation`), in order to launch queries on the data store as a response to a user event: +The `useQuery` hook automatically subscribes to changes and updates your component when the query results change. The `useExecuteQuery` hook returns a function that executes DQL mutations when called. It can also be used to lazily execute non-mutating queries in responce to user actions. + +For more complex scenarios, you can also pass parameters to your queries: ```tsx -import { useLazyPendingCursorOperation } from '@dittolive/react-ditto' +import { useQuery, useExecuteQuery } from '@dittolive/react-ditto' export default function App() { - const { documents, exec } = useLazyPendingCursorOperation() + const [filter, setFilter] = useState<'all' | 'completed' | 'active'>('all') + + // Query with parameters + const { documents } = useQuery( + 'SELECT * FROM tasks WHERE (:filter = "all" OR isCompleted = :showCompleted)', + { + args: { + filter, + showCompleted: filter === 'completed' + } + } + ) - if (!documents?.length) { - return ( - - ) - } + // Update task completion status + const [setCompleted] = useExecuteQuery( + 'UPDATE tasks SET isCompleted = :isCompleted WHERE _id = :id' + ) return ( -
    - {documents.map((doc) => ( -
  • {JSON.stringify(doc.value)}
  • - ))} -
+
+ + +
    + {documents.map((doc) => ( +
  • + setCompleted({ id: doc.value._id, isCompleted: e.target.checked })} + /> + {doc.value.text} +
  • + ))} +
+
) } ``` diff --git a/examples/vite-typescript-example-legacy/.gitignore b/examples/vite-typescript-example-legacy/.gitignore new file mode 100644 index 0000000..26919a9 --- /dev/null +++ b/examples/vite-typescript-example-legacy/.gitignore @@ -0,0 +1,26 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# production +/build + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# editors +.idea \ No newline at end of file diff --git a/examples/vite-typescript-example-legacy/README.md b/examples/vite-typescript-example-legacy/README.md new file mode 100644 index 0000000..6247c18 --- /dev/null +++ b/examples/vite-typescript-example-legacy/README.md @@ -0,0 +1,40 @@ +# Getting Started with Vite + +This project was bootstrapped with [Vite](https://vite.dev/). + +## Available Scripts + +**Note: on newer versions of Node, you may run into `ERR_OSSL_EVP_UNSUPPORTED` errors. You may pass the command-line option of `--openssl-legacy-provider` to work around this. Refer to [Node v17 release notes](https://nodejs.org/es/blog/release/v17.0.0/#openssl-3-0).** + +In the project directory, you can run: + +### `yarn dev` + +Runs the app in the development mode.\ +Open [http://localhost:3000](http://localhost:3000) to view it in the browser. + +The page will reload if you make edits.\ +You will also see any lint errors in the console. + +### `yarn test` + +Launches the test runner in the interactive watch mode. + +### `yarn build` + +Builds the app for production to the `dist` folder.\ +It correctly bundles React in production mode and optimizes the build for the best performance. + +### `yarn type-check` + +Runs the TypeScript type checker + +### `yarn lint` + +Runs `eslint` to find and fix any configured linting issues + +## Learn More + +You can learn more in the [Vite documentation](https://vite.dev/guide/). + +To learn React, check out the [React documentation](https://react.dev/). diff --git a/examples/vite-typescript-example-legacy/babel.config.json b/examples/vite-typescript-example-legacy/babel.config.json new file mode 100644 index 0000000..7eaab8d --- /dev/null +++ b/examples/vite-typescript-example-legacy/babel.config.json @@ -0,0 +1,12 @@ +{ + "presets": [ + "@babel/preset-env", + [ + "@babel/preset-react", + { + "runtime": "automatic" + } + ], + "@babel/preset-typescript" + ] +} diff --git a/examples/vite-typescript-example-legacy/eslint.config.mjs b/examples/vite-typescript-example-legacy/eslint.config.mjs new file mode 100644 index 0000000..ee86391 --- /dev/null +++ b/examples/vite-typescript-example-legacy/eslint.config.mjs @@ -0,0 +1,62 @@ +import pluginJs from '@eslint/js' +import prettierPlugin from 'eslint-plugin-prettier/recommended' +import pluginReact from 'eslint-plugin-react' +import pluginReactHooks from 'eslint-plugin-react-hooks' +import simpleImportSort from 'eslint-plugin-simple-import-sort' +import keySort from 'eslint-plugin-sort-keys-fix' +import globals from 'globals' +import tseslint from 'typescript-eslint' + +export default [ + { + ignores: ['**/dist/*', '**/target/**'], + }, + { + files: ['**/*.{js,mjs,cjs,ts,jsx,tsx}'], + plugins: { + 'simple-import-sort': simpleImportSort, + react: pluginReact, + 'react-hooks': pluginReactHooks, + 'sort-keys-fix': keySort, + }, + settings: { + react: { + version: 'detect', + }, + }, + rules: { + 'no-console': ['error', { allow: ['warn', 'error'] }], + 'simple-import-sort/imports': 'error', + 'simple-import-sort/exports': 'error', + 'sort-imports': 'off', + semi: 0, + 'prettier/prettier': [ + 'error', + { + semi: false, + }, + ], + ...pluginReactHooks.configs.recommended.rules, + }, + }, + { languageOptions: { globals: globals.node } }, + { languageOptions: { globals: globals.browser } }, + pluginJs.configs.recommended, + ...tseslint.configs.recommended, + prettierPlugin, + pluginReact.configs.flat.recommended, + pluginReact.configs.flat['jsx-runtime'], + { + files: ['**/*.test.{ts,tsx}', '**/__tests__/**'], + languageOptions: { globals: globals.jest }, + rules: { + '@typescript-eslint/no-explicit-any': 'off', + }, + }, + { + files: ['**/*.config.js'], + rules: { + '@typescript-eslint/no-require-imports': 'off', + }, + }, +] diff --git a/examples/vite-typescript-example-legacy/index.html b/examples/vite-typescript-example-legacy/index.html new file mode 100644 index 0000000..1cc83e2 --- /dev/null +++ b/examples/vite-typescript-example-legacy/index.html @@ -0,0 +1,16 @@ + + + + + + + + + + Ditto + Vite + React + + +
+ + + diff --git a/examples/vite-typescript-example-legacy/jest.config.ts b/examples/vite-typescript-example-legacy/jest.config.ts new file mode 100644 index 0000000..68f5ebc --- /dev/null +++ b/examples/vite-typescript-example-legacy/jest.config.ts @@ -0,0 +1,14 @@ +import type { Config } from 'jest' + +export default { + moduleNameMapper: { + '^@/(.*)$': '/src/$1', + '\\.css$': 'identity-obj-proxy', + }, + setupFilesAfterEnv: ['/jest.setup.ts'], + modulePathIgnorePatterns: ['/dist/'], + testEnvironment: 'jsdom', + transform: { + '^.+\\.tsx?$': 'babel-jest', + }, +} satisfies Config diff --git a/examples/vite-typescript-example-legacy/jest.setup.ts b/examples/vite-typescript-example-legacy/jest.setup.ts new file mode 100644 index 0000000..8bc47dc --- /dev/null +++ b/examples/vite-typescript-example-legacy/jest.setup.ts @@ -0,0 +1,14 @@ +// jest-dom adds custom jest matchers for asserting on DOM nodes. +// allows you to do things like: +// expect(element).toHaveTextContent(/react/i) +// learn more: https://github.com/testing-library/jest-dom +import '@testing-library/jest-dom' + +// JSDom, which is used by @testing-library/react, doesn't support TextEncoder and TextDecoder, +// even though they have broad support in modern browsers. This polyfill adds them to the global +// object so that they can be used by @dittolive/ditto. +// cf. https://github.com/jsdom/jsdom/issues/2524 +import { TextDecoder, TextEncoder } from 'util' +global.TextEncoder = TextEncoder +// @ts-expect-error: The type mismatch is acceptable +global.TextDecoder = TextDecoder diff --git a/examples/vite-typescript-example-legacy/package.json b/examples/vite-typescript-example-legacy/package.json new file mode 100644 index 0000000..ca03d96 --- /dev/null +++ b/examples/vite-typescript-example-legacy/package.json @@ -0,0 +1,67 @@ +{ + "name": "vite-typescript-example", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "test": "jest", + "type-check": "tsc --noEmit", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@dittolive/ditto": "link:../../node_modules/@dittolive/ditto", + "@dittolive/react-ditto": "link:../../", + "react": "link:../../node_modules/react", + "react-dom": "link:../../node_modules/react-dom", + "react-select": "^5.8.0", + "uuid": "^9.0.1" + }, + "devDependencies": { + "@babel/preset-env": "^7.26.0", + "@babel/preset-react": "^7.25.9", + "@babel/preset-typescript": "^7.26.0", + "@eslint/js": "^9.15.0", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.0.1", + "@testing-library/user-event": "^14.5.2", + "@types/jest": "^29.5.14", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@types/uuid": "^10.0.0", + "@vitejs/plugin-react": "^4.3.3", + "babel-jest": "^29.7.0", + "eslint": "^9.15.0", + "eslint-plugin-prettier": "^5.2.1", + "eslint-plugin-react-hooks": "^5.0.0", + "eslint-plugin-react-refresh": "^0.4.14", + "eslint-plugin-simple-import-sort": "^12.1.1", + "eslint-plugin-sort-keys-fix": "^1.1.2", + "globals": "^15.12.0", + "identity-obj-proxy": "^3.0.0", + "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", + "prettier": "3.2.5", + "prettier-eslint": "^16.3.0", + "ts-node": "^10.9.2", + "typescript": "^4.9", + "typescript-eslint": "^8.15.0", + "vite": "^5.4.11" + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "engines": { + "node": ">=18" + } +} \ No newline at end of file diff --git a/examples/vite-typescript-example-legacy/public/favicon.ico b/examples/vite-typescript-example-legacy/public/favicon.ico new file mode 100644 index 0000000..a11777c Binary files /dev/null and b/examples/vite-typescript-example-legacy/public/favicon.ico differ diff --git a/examples/vite-typescript-example-legacy/public/logo192.png b/examples/vite-typescript-example-legacy/public/logo192.png new file mode 100644 index 0000000..fc44b0a Binary files /dev/null and b/examples/vite-typescript-example-legacy/public/logo192.png differ diff --git a/examples/vite-typescript-example-legacy/public/logo512.png b/examples/vite-typescript-example-legacy/public/logo512.png new file mode 100644 index 0000000..a4e47a6 Binary files /dev/null and b/examples/vite-typescript-example-legacy/public/logo512.png differ diff --git a/examples/vite-typescript-example-legacy/public/manifest.json b/examples/vite-typescript-example-legacy/public/manifest.json new file mode 100644 index 0000000..080d6c7 --- /dev/null +++ b/examples/vite-typescript-example-legacy/public/manifest.json @@ -0,0 +1,25 @@ +{ + "short_name": "React App", + "name": "Create React App Sample", + "icons": [ + { + "src": "favicon.ico", + "sizes": "64x64 32x32 24x24 16x16", + "type": "image/x-icon" + }, + { + "src": "logo192.png", + "type": "image/png", + "sizes": "192x192" + }, + { + "src": "logo512.png", + "type": "image/png", + "sizes": "512x512" + } + ], + "start_url": ".", + "display": "standalone", + "theme_color": "#000000", + "background_color": "#ffffff" +} diff --git a/examples/vite-typescript-example-legacy/public/robots.txt b/examples/vite-typescript-example-legacy/public/robots.txt new file mode 100644 index 0000000..e9e57dc --- /dev/null +++ b/examples/vite-typescript-example-legacy/public/robots.txt @@ -0,0 +1,3 @@ +# https://www.robotstxt.org/robotstxt.html +User-agent: * +Disallow: diff --git a/examples/vite-typescript-example-legacy/src/App.css b/examples/vite-typescript-example-legacy/src/App.css new file mode 100644 index 0000000..37c9870 --- /dev/null +++ b/examples/vite-typescript-example-legacy/src/App.css @@ -0,0 +1,44 @@ +.App { + text-align: center; +} + +.App-logo { + height: 40vmin; + pointer-events: none; +} + +@media (prefers-reduced-motion: no-preference) { + .App-logo { + animation: App-logo-spin infinite 20s linear; + } +} + +.App-header { + background-color: #282c34; + min-height: 100vh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + font-size: calc(10px + 2vmin); + color: white; +} + +.App-link { + color: #61dafb; +} + +@keyframes App-logo-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +ul.no-bullets { + list-style-type: none; /* Remove bullets */ + padding: 0; /* Remove padding */ + margin: 0; /* Remove margins */ +} diff --git a/examples/vite-typescript-example-legacy/src/App.test.tsx b/examples/vite-typescript-example-legacy/src/App.test.tsx new file mode 100644 index 0000000..626b969 --- /dev/null +++ b/examples/vite-typescript-example-legacy/src/App.test.tsx @@ -0,0 +1,9 @@ +import { render, screen } from '@testing-library/react' + +import App from './App' + +test('renders learn react link', () => { + render() + const linkElement = screen.getByText(/Using Ditto with path/i) + expect(linkElement).toBeInTheDocument() +}) diff --git a/examples/vite-typescript-example-legacy/src/App.tsx b/examples/vite-typescript-example-legacy/src/App.tsx new file mode 100644 index 0000000..1523653 --- /dev/null +++ b/examples/vite-typescript-example-legacy/src/App.tsx @@ -0,0 +1,91 @@ +import './App.css' + +import { useMutations, usePendingCursorOperation } from '@dittolive/react-ditto' +import { useMemo, useState } from 'react' + +type Props = { + path: string +} + +const App = ({ path }: Props) => { + const [newBodyText, setNewBodyText] = useState('') + const params = useMemo( + () => ({ + path: path, + collection: 'tasks', + }), + [path], + ) + const { documents: tasks } = usePendingCursorOperation(params) + const { upsert, removeByID, updateByID } = useMutations({ + collection: 'tasks', + path: path, + }) + + return ( +
+

Using Ditto with path “{path}“

+ Number of tasks {tasks.length} + +
+ setNewBodyText(e.currentTarget.value)} + /> + +
+
    + {tasks.map((task) => { + return ( +
  • +

    DocumentId: {task.id.value}

    +

    Body: {task.value.body}

    +

    + Is Completed:{' '} + {task.value.isCompleted ? 'Completed' : 'Not Completed'} +

    + + +
  • + ) + })} +
+
+ ) +} + +export default App diff --git a/examples/vite-typescript-example-legacy/src/AppContainer.tsx b/examples/vite-typescript-example-legacy/src/AppContainer.tsx new file mode 100644 index 0000000..59b81b9 --- /dev/null +++ b/examples/vite-typescript-example-legacy/src/AppContainer.tsx @@ -0,0 +1,100 @@ +import { Ditto } from '@dittolive/ditto' +import { + DittoProvider, + useOfflinePlaygroundIdentity, + useOnlineIdentity, +} from '@dittolive/react-ditto' +import React, { useState } from 'react' +import { default as ReactSelect, SingleValue } from 'react-select' +import { v4 as uuidv4 } from 'uuid' + +import App from './App' +import AuthenticationPanel from './AuthenticationPanel' + +interface IdentityOption { + name: string + path: string +} +const options: IdentityOption[] = [ + { path: '/path-development', name: 'Development' }, + { path: '/path-online', name: 'Online' }, +] + +/** + * Container component that shows how to initialize the DittoProvider component. + * */ +const AppContainer: React.FC = () => { + const { create: createDevelopment } = useOfflinePlaygroundIdentity() + const { create: createOnline, getAuthenticationRequired } = + useOnlineIdentity() + const [currentPath, setCurrentPath] = useState('/path-development') + + const handleCreateDittoInstances = () => { + // Example of how to create a development instance + const dittoDevelopment = new Ditto( + createDevelopment({ appID: 'live.ditto.example', siteID: 1234 }), + '/path-development', + ) + + // Example of how to create an online instance with authentication enabled + const dittoOnline = new Ditto( + createOnline( + { + // If you're using the Ditto cloud this ID should be the app ID shown on your app settings page, on the portal. + appID: uuidv4(), + // enableDittoCloudSync: true, + }, + '/path-online', + ), + '/path-online', + ) + return [dittoDevelopment, dittoOnline] + } + + return ( + <> +
+ + + getOptionLabel={(animal: IdentityOption) => animal.name} + getOptionValue={(animal: IdentityOption) => animal.path} + options={options} + value={options.find((opt) => opt.path === currentPath)} + onChange={(nextOption: SingleValue) => + setCurrentPath(nextOption!.path) + } + /> +
+ + {({ loading, error }) => { + if (loading) { + return

Loading

+ } + if (error) { + return

Error: {JSON.stringify(error)}

+ } + + return ( + <> + + + + ) + }} +
+ + ) +} + +export default AppContainer diff --git a/examples/vite-typescript-example-legacy/src/AuthenticationPanel.tsx b/examples/vite-typescript-example-legacy/src/AuthenticationPanel.tsx new file mode 100644 index 0000000..8e3b20b --- /dev/null +++ b/examples/vite-typescript-example-legacy/src/AuthenticationPanel.tsx @@ -0,0 +1,91 @@ +import { useDitto } from '@dittolive/react-ditto' +import React, { useState } from 'react' + +type Props = { + /** True if authentication is required */ + isAuthRequired: boolean + /** Current active path */ + path: string +} + +/** Simple authenticate panel for the user to input a token and a token provider. + */ +const AuthenticationPanel: React.FC = ({ path, isAuthRequired }) => { + const [token, setToken] = useState('') + const [provider, setProvider] = useState('') + const [authError, setAuthError] = useState() + const { ditto } = useDitto(path) + const [isAuthenticated, setIsAuthenticated] = useState( + !!ditto?.auth.status.isAuthenticated, + ) + + if (!ditto || !isAuthRequired || isAuthenticated) { + return null + } + + return ( +
+
{ + evt.preventDefault() + ditto.auth + .loginWithToken(token, provider) + .then(() => setIsAuthenticated(ditto.auth.status.isAuthenticated)) + .catch((err) => { + setAuthError(err) + }) + }} + > +

Authentication required

+
+
+ + setProvider(evt.target.value)} + /> +
+
+ +