mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-12 04:18:54 +00:00
Compare commits
9 Commits
v0.26.1-be
...
v2026.2.10
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ad482351b | ||
|
|
03b1d15a8f | ||
|
|
52c7b04a01 | ||
|
|
1c0f873c9d | ||
|
|
8b68574820 | ||
|
|
bb01bb1aef | ||
|
|
8192a492d9 | ||
|
|
31e11b2563 | ||
|
|
5a36acea7b |
16
.github/actions/deploy/deploy.mjs
vendored
16
.github/actions/deploy/deploy.mjs
vendored
@@ -25,7 +25,9 @@ const buildType = BUILD_TYPE || 'canary';
|
|||||||
|
|
||||||
const isProduction = buildType === 'stable';
|
const isProduction = buildType === 'stable';
|
||||||
const isBeta = buildType === 'beta';
|
const isBeta = buildType === 'beta';
|
||||||
|
const isCanary = buildType === 'canary';
|
||||||
const isInternal = buildType === 'internal';
|
const isInternal = buildType === 'internal';
|
||||||
|
const isSpotEnabled = isBeta || isCanary;
|
||||||
|
|
||||||
const replicaConfig = {
|
const replicaConfig = {
|
||||||
stable: {
|
stable: {
|
||||||
@@ -72,6 +74,9 @@ const createHelmCommand = ({ isDryRun }) => {
|
|||||||
`--set-string global.indexer.endpoint="${AFFINE_INDEXER_SEARCH_ENDPOINT}"`,
|
`--set-string global.indexer.endpoint="${AFFINE_INDEXER_SEARCH_ENDPOINT}"`,
|
||||||
`--set-string global.indexer.apiKey="${AFFINE_INDEXER_SEARCH_API_KEY}"`,
|
`--set-string global.indexer.apiKey="${AFFINE_INDEXER_SEARCH_API_KEY}"`,
|
||||||
];
|
];
|
||||||
|
const cloudSqlNodeSelector = isBeta
|
||||||
|
? `{ \\"iam.gke.io/gke-metadata-server-enabled\\": \\"true\\", \\"cloud.google.com/gke-spot\\": \\"true\\" }`
|
||||||
|
: `{ \\"iam.gke.io/gke-metadata-server-enabled\\": \\"true\\" }`;
|
||||||
const serviceAnnotations = [
|
const serviceAnnotations = [
|
||||||
`--set-json front.serviceAccount.annotations="{ \\"iam.gke.io/gcp-service-account\\": \\"${APP_IAM_ACCOUNT}\\" }"`,
|
`--set-json front.serviceAccount.annotations="{ \\"iam.gke.io/gcp-service-account\\": \\"${APP_IAM_ACCOUNT}\\" }"`,
|
||||||
`--set-json graphql.serviceAccount.annotations="{ \\"iam.gke.io/gcp-service-account\\": \\"${APP_IAM_ACCOUNT}\\" }"`,
|
`--set-json graphql.serviceAccount.annotations="{ \\"iam.gke.io/gcp-service-account\\": \\"${APP_IAM_ACCOUNT}\\" }"`,
|
||||||
@@ -84,10 +89,18 @@ const createHelmCommand = ({ isDryRun }) => {
|
|||||||
`--set-json front.services.renderer.annotations="{ \\"cloud.google.com/neg\\": \\"{\\\\\\"ingress\\\\\\": true}\\" }"`,
|
`--set-json front.services.renderer.annotations="{ \\"cloud.google.com/neg\\": \\"{\\\\\\"ingress\\\\\\": true}\\" }"`,
|
||||||
`--set-json graphql.service.annotations="{ \\"cloud.google.com/neg\\": \\"{\\\\\\"ingress\\\\\\": true}\\" }"`,
|
`--set-json graphql.service.annotations="{ \\"cloud.google.com/neg\\": \\"{\\\\\\"ingress\\\\\\": true}\\" }"`,
|
||||||
`--set-json cloud-sql-proxy.serviceAccount.annotations="{ \\"iam.gke.io/gcp-service-account\\": \\"${CLOUD_SQL_IAM_ACCOUNT}\\" }"`,
|
`--set-json cloud-sql-proxy.serviceAccount.annotations="{ \\"iam.gke.io/gcp-service-account\\": \\"${CLOUD_SQL_IAM_ACCOUNT}\\" }"`,
|
||||||
`--set-json cloud-sql-proxy.nodeSelector="{ \\"iam.gke.io/gke-metadata-server-enabled\\": \\"true\\" }"`,
|
`--set-json cloud-sql-proxy.nodeSelector="${cloudSqlNodeSelector}"`,
|
||||||
]
|
]
|
||||||
: []
|
: []
|
||||||
);
|
);
|
||||||
|
const spotNodeSelector = `{ \\"cloud.google.com/gke-spot\\": \\"true\\" }`;
|
||||||
|
const spotScheduling = isSpotEnabled
|
||||||
|
? [
|
||||||
|
`--set-json front.nodeSelector="${spotNodeSelector}"`,
|
||||||
|
`--set-json graphql.nodeSelector="${spotNodeSelector}"`,
|
||||||
|
`--set-json doc.nodeSelector="${spotNodeSelector}"`,
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
|
||||||
const cpu = cpuConfig[buildType];
|
const cpu = cpuConfig[buildType];
|
||||||
const memory = memoryConfig[buildType];
|
const memory = memoryConfig[buildType];
|
||||||
@@ -146,6 +159,7 @@ const createHelmCommand = ({ isDryRun }) => {
|
|||||||
`--set-string doc.app.host="${primaryHost}"`,
|
`--set-string doc.app.host="${primaryHost}"`,
|
||||||
`--set doc.replicaCount=${replica.doc}`,
|
`--set doc.replicaCount=${replica.doc}`,
|
||||||
...serviceAnnotations,
|
...serviceAnnotations,
|
||||||
|
...spotScheduling,
|
||||||
...resources,
|
...resources,
|
||||||
`--timeout 10m`,
|
`--timeout 10m`,
|
||||||
flag,
|
flag,
|
||||||
|
|||||||
5
.github/helm/affine/charts/doc/values.yaml
vendored
5
.github/helm/affine/charts/doc/values.yaml
vendored
@@ -30,9 +30,12 @@ podSecurityContext:
|
|||||||
fsGroup: 2000
|
fsGroup: 2000
|
||||||
|
|
||||||
resources:
|
resources:
|
||||||
requests:
|
limits:
|
||||||
cpu: '1'
|
cpu: '1'
|
||||||
memory: 4Gi
|
memory: 4Gi
|
||||||
|
requests:
|
||||||
|
cpu: '1'
|
||||||
|
memory: 2Gi
|
||||||
|
|
||||||
probe:
|
probe:
|
||||||
initialDelaySeconds: 20
|
initialDelaySeconds: 20
|
||||||
|
|||||||
7
.github/helm/affine/charts/front/values.yaml
vendored
7
.github/helm/affine/charts/front/values.yaml
vendored
@@ -29,9 +29,12 @@ podSecurityContext:
|
|||||||
fsGroup: 2000
|
fsGroup: 2000
|
||||||
|
|
||||||
resources:
|
resources:
|
||||||
|
limits:
|
||||||
|
cpu: '1'
|
||||||
|
memory: 2Gi
|
||||||
requests:
|
requests:
|
||||||
cpu: '2'
|
cpu: '1'
|
||||||
memory: 4Gi
|
memory: 2Gi
|
||||||
|
|
||||||
probe:
|
probe:
|
||||||
initialDelaySeconds: 20
|
initialDelaySeconds: 20
|
||||||
|
|||||||
@@ -27,8 +27,11 @@ podSecurityContext:
|
|||||||
fsGroup: 2000
|
fsGroup: 2000
|
||||||
|
|
||||||
resources:
|
resources:
|
||||||
|
limits:
|
||||||
|
cpu: '1'
|
||||||
|
memory: 4Gi
|
||||||
requests:
|
requests:
|
||||||
cpu: '2'
|
cpu: '1'
|
||||||
memory: 2Gi
|
memory: 2Gi
|
||||||
|
|
||||||
probe:
|
probe:
|
||||||
|
|||||||
72
.github/workflows/sync-i18n.yml
vendored
72
.github/workflows/sync-i18n.yml
vendored
@@ -1,72 +0,0 @@
|
|||||||
name: Sync I18n with Crowdin
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- canary
|
|
||||||
paths:
|
|
||||||
- 'packages/frontend/i18n/**'
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
synchronize-with-crowdin:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
pull-requests: write
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Crowdin action
|
|
||||||
id: crowdin
|
|
||||||
uses: crowdin/github-action@v2
|
|
||||||
with:
|
|
||||||
upload_sources: true
|
|
||||||
upload_translations: false
|
|
||||||
download_translations: true
|
|
||||||
auto_approve_imported: true
|
|
||||||
import_eq_suggestions: true
|
|
||||||
export_only_approved: true
|
|
||||||
skip_untranslated_strings: true
|
|
||||||
localization_branch_name: l10n_crowdin_translations
|
|
||||||
create_pull_request: true
|
|
||||||
pull_request_title: 'chore(i18n): sync translations'
|
|
||||||
pull_request_body: 'New Crowdin translations by [Crowdin GH Action](https://github.com/crowdin/github-action)'
|
|
||||||
pull_request_base_branch_name: 'canary'
|
|
||||||
config: packages/frontend/i18n/crowdin.yml
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
|
||||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
|
||||||
i18n-codegen:
|
|
||||||
needs: synchronize-with-crowdin
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
pull-requests: write
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
ref: l10n_crowdin_translations
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: ./.github/actions/setup-node
|
|
||||||
with:
|
|
||||||
electron-install: false
|
|
||||||
full-cache: true
|
|
||||||
|
|
||||||
- name: Run i18n codegen
|
|
||||||
run: yarn affine @affine/i18n build
|
|
||||||
|
|
||||||
- name: Commit changes
|
|
||||||
run: |
|
|
||||||
git config user.name "github-actions[bot]"
|
|
||||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
|
||||||
git add .
|
|
||||||
git commit -m "chore(i18n): i18n codegen"
|
|
||||||
git push origin l10n_crowdin_translations
|
|
||||||
13
.vscode/settings.template.json
vendored
13
.vscode/settings.template.json
vendored
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"eslint.packageManager": "yarn",
|
"prisma.pinToPrisma6": true,
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
"editor.formatOnSave": true,
|
"editor.formatOnSave": true,
|
||||||
"editor.formatOnSaveMode": "file",
|
"editor.formatOnSaveMode": "file",
|
||||||
@@ -14,11 +14,13 @@
|
|||||||
"testid",
|
"testid",
|
||||||
"schemars"
|
"schemars"
|
||||||
],
|
],
|
||||||
|
"explorer.fileNesting.enabled": true,
|
||||||
"explorer.fileNesting.patterns": {
|
"explorer.fileNesting.patterns": {
|
||||||
"*.js": "${capture}.js.map, ${capture}.min.js, ${capture}.d.ts, ${capture}.d.ts.map",
|
"*.js": "${capture}.js.map, ${capture}.min.js, ${capture}.d.ts, ${capture}.d.ts.map",
|
||||||
"package.json": ".browserslist*, .circleci*, .codecov, .commitlint*, .cz-config.js, .czrc, .dlint.json, .dprint.json, .editorconfig, .eslint*, .firebase*, .flowconfig, .github*, .gitlab*, .gitpod*, .huskyrc*, .jslint*, .lighthouserc.*, .lintstagedrc*, .markdownlint*, .mocha*, .node-version, .nodemon*, .npm*, .nvmrc, .pm2*, .pnp.*, .pnpm*, .prettier*, .releaserc*, .sentry*, .stackblitz*, .styleci*, .stylelint*, .tazerc*, .textlint*, .tool-versions, .travis*, .versionrc*, .vscode*, .watchman*, .xo-config*, .yamllint*, .yarnrc*, Procfile, api-extractor.json, apollo.config.*, appveyor*, ava.config.*, azure-pipelines*, bower.json, build.config.*, commitlint*, crowdin*, cypress.*, dangerfile*, dlint.json, dprint.json, firebase.json, grunt*, gulp*, histoire.config.*, jasmine.*, jenkins*, jest.config.*, jsconfig.*, karma*, lerna*, lighthouserc.*, lint-staged*, nest-cli.*, netlify*, nodemon*, nx.*, package-lock.json, package.nls*.json, phpcs.xml, playwright.config.*, pm2.*, pnpm*, prettier*, pullapprove*, puppeteer.config.*, pyrightconfig.json, release-tasks.sh, renovate*, rollup.config.*, stylelint*, tsconfig.*, tsdoc.*, tslint*, tsup.config.*, turbo*, typedoc*, unlighthouse*, vercel*, vetur.config.*, vitest.config.*, webpack*, workspace.json, xo.config.*, yarn*, babel.*, .babelrc, project.json",
|
"package.json": ".browserslist*, .circleci*, .codecov, .commitlint*, .cz-config.js, .czrc, .dlint.json, .dprint.json, .editorconfig, .eslint*, eslint.*, .firebase*, .flowconfig, .github*, .gitlab*, .gitpod*, .huskyrc*, .jslint*, .lighthouserc.*, .lintstagedrc*, .markdownlint*, .mocha*, .node-version, .nodemon*, .npm*, .nvmrc, .pm2*, .pnp.*, .pnpm*, .prettier*, .releaserc*, .sentry*, .stackblitz*, .styleci*, .stylelint*, .tazerc*, .textlint*, .tool-versions, .travis*, .versionrc*, .vscode*, .watchman*, .xo-config*, .yamllint*, .yarnrc*, Procfile, api-extractor.json, apollo.config.*, appveyor*, ava.config.*, azure-pipelines*, bower.json, build.config.*, commitlint*, dangerfile*, dlint.json, dprint.json, firebase.json, grunt*, gulp*, histoire.config.*, jasmine.*, jenkins*, jest.config.*, jsconfig.*, karma*, lerna*, lighthouserc.*, lint-staged*, nest-cli.*, netlify*, nodemon*, nx.*, package-lock.json, package.nls*.json, phpcs.xml, playwright.config.*, pm2.*, pnpm*, prettier*, pullapprove*, puppeteer.config.*, pyrightconfig.json, release-tasks.sh, renovate*, rollup.config.*, stylelint*, tsconfig.*, tsdoc.*, tslint*, tsup.config.*, turbo*, typedoc*, unlighthouse*, vercel*, vetur.config.*, vitest.*, webpack*, workspace.json, xo.config.*, yarn*, babel.*, .babelrc, project.json, oxlint.json, nyc.config.*",
|
||||||
"Cargo.toml": "Cargo.lock",
|
"Cargo.toml": "Cargo.lock, rust-toolchain*, rustfmt.toml, .taplo.toml",
|
||||||
"README.md": "LICENSE, CHANGELOG.md, CODE_OF_CONDUCT.md, CONTRIBUTING.md"
|
"README.md": "LICENSE*, CHANGELOG.md, CODE_OF_CONDUCT.md, CONTRIBUTING.md, SECURITY.md, README.*",
|
||||||
|
".gitignore": ".gitattributes, .dockerignore, .eslintignore, .prettierignore, .stylelintignore, .tslintignore, .yarnignore"
|
||||||
},
|
},
|
||||||
"[rust]": {
|
"[rust]": {
|
||||||
"editor.defaultFormatter": "rust-lang.rust-analyzer"
|
"editor.defaultFormatter": "rust-lang.rust-analyzer"
|
||||||
@@ -32,5 +34,6 @@
|
|||||||
"vitest.include": ["packages/**/*.spec.ts", "packages/**/*.spec.tsx"],
|
"vitest.include": ["packages/**/*.spec.ts", "packages/**/*.spec.tsx"],
|
||||||
"rust-analyzer.check.extraEnv": {
|
"rust-analyzer.check.extraEnv": {
|
||||||
"DATABASE_URL": "sqlite:affine.db"
|
"DATABASE_URL": "sqlite:affine.db"
|
||||||
}
|
},
|
||||||
|
"typescript.tsdk": "node_modules/typescript/lib"
|
||||||
}
|
}
|
||||||
|
|||||||
35
README.md
35
README.md
@@ -21,23 +21,6 @@
|
|||||||
<br/>
|
<br/>
|
||||||
<br/>
|
<br/>
|
||||||
|
|
||||||
<div align="left" valign="middle">
|
|
||||||
<a href="https://runblaze.dev">
|
|
||||||
<picture>
|
|
||||||
<source media="(prefers-color-scheme: dark)" srcset="https://www.runblaze.dev/logo_dark.png">
|
|
||||||
<img align="right" src="https://www.runblaze.dev/logo_light.png" height="102px"/>
|
|
||||||
</picture>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<br style="display: none;"/>
|
|
||||||
|
|
||||||
_Special thanks to [Blaze](https://runblaze.dev) for their support of this project. They provide high-performance Apple Silicon macOS and Linux (AMD64 & ARM64) runners for GitHub Actions, greatly reducing our automated build times._
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<br/>
|
|
||||||
<br/>
|
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<a href="https://affine.pro">Home Page</a> |
|
<a href="https://affine.pro">Home Page</a> |
|
||||||
<a href="https://affine.pro/redirect/discord">Discord</a> |
|
<a href="https://affine.pro/redirect/discord">Discord</a> |
|
||||||
@@ -107,10 +90,10 @@ Thanks for checking us out, we appreciate your interest and sincerely hope that
|
|||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
| Bug Reports | Feature Requests | Questions/Discussions | AFFiNE Community |
|
| Bug Reports | Feature Requests | Questions/Discussions | AFFiNE Community |
|
||||||
| --------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | --------------------------------------------------------- |
|
| --------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | ---------------------------------------------------------- |
|
||||||
| [Create a bug report](https://github.com/toeverything/AFFiNE/issues/new?assignees=&labels=bug%2Cproduct-review&template=BUG-REPORT.yml&title=TITLE) | [Submit a feature request](https://github.com/toeverything/AFFiNE/issues/new?assignees=&labels=feat%2Cproduct-review&template=FEATURE-REQUEST.yml&title=TITLE) | [Check GitHub Discussion](https://github.com/toeverything/AFFiNE/discussions) | [Vist the AFFiNE Community](https://community.affine.pro) |
|
| [Create a bug report](https://github.com/toeverything/AFFiNE/issues/new?assignees=&labels=bug%2Cproduct-review&template=BUG-REPORT.yml&title=TITLE) | [Submit a feature request](https://github.com/toeverything/AFFiNE/issues/new?assignees=&labels=feat%2Cproduct-review&template=FEATURE-REQUEST.yml&title=TITLE) | [Check GitHub Discussion](https://github.com/toeverything/AFFiNE/discussions) | [Visit the AFFiNE Community](https://community.affine.pro) |
|
||||||
| Something isn't working as expected | An idea for a new feature, or improvements | Discuss and ask questions | A place to ask, learn and engage with others |
|
| Something isn't working as expected | An idea for a new feature, or improvements | Discuss and ask questions | A place to ask, learn and engage with others |
|
||||||
|
|
||||||
Calling all developers, testers, tech writers and more! Contributions of all types are more than welcome, you can read more in [docs/types-of-contributions.md](docs/types-of-contributions.md). If you are interested in contributing code, read our [docs/CONTRIBUTING.md](docs/CONTRIBUTING.md) and feel free to check out our GitHub issues to get stuck in to show us what you’re made of.
|
Calling all developers, testers, tech writers and more! Contributions of all types are more than welcome, you can read more in [docs/types-of-contributions.md](docs/types-of-contributions.md). If you are interested in contributing code, read our [docs/CONTRIBUTING.md](docs/CONTRIBUTING.md) and feel free to check out our GitHub issues to get stuck in to show us what you’re made of.
|
||||||
|
|
||||||
@@ -169,8 +152,10 @@ Welcome to the AFFiNE blog section! Here, you’ll find the latest insights, tip
|
|||||||
We would also like to give thanks to open-source projects that make AFFiNE possible:
|
We would also like to give thanks to open-source projects that make AFFiNE possible:
|
||||||
|
|
||||||
- [Blocksuite](https://github.com/toeverything/BlockSuite) - 💠 BlockSuite is the open-source collaborative editor project behind AFFiNE.
|
- [Blocksuite](https://github.com/toeverything/BlockSuite) - 💠 BlockSuite is the open-source collaborative editor project behind AFFiNE.
|
||||||
|
- [y-octo](https://github.com/y-crdt/y-octo) - 🐙 y-octo is a native, high-performance, thread-safe YJS CRDT implementation, serving as the core engine enabling the AFFiNE Client/Server to achieve "local-first" functionality.
|
||||||
- [OctoBase](https://github.com/toeverything/OctoBase) - 🐙 OctoBase is the open-source database behind AFFiNE, local-first, yet collaborative. A light-weight, scalable, data engine written in Rust.
|
- [OctoBase](https://github.com/toeverything/OctoBase) - 🐙 OctoBase is the open-source database behind AFFiNE, local-first, yet collaborative. A light-weight, scalable, data engine written in Rust.
|
||||||
- [yjs](https://github.com/yjs/yjs) - Fundamental support of CRDTs for our implementation on state management and data sync.
|
|
||||||
|
- [yjs](https://github.com/yjs/yjs) - Fundamental support of CRDTs for our implementation on state management and data sync on web.
|
||||||
- [electron](https://github.com/electron/electron) - Build cross-platform desktop apps with JavaScript, HTML, and CSS.
|
- [electron](https://github.com/electron/electron) - Build cross-platform desktop apps with JavaScript, HTML, and CSS.
|
||||||
- [React](https://github.com/facebook/react) - The library for web and native user interfaces.
|
- [React](https://github.com/facebook/react) - The library for web and native user interfaces.
|
||||||
- [napi-rs](https://github.com/napi-rs/napi-rs) - A framework for building compiled Node.js add-ons in Rust via Node-API.
|
- [napi-rs](https://github.com/napi-rs/napi-rs) - A framework for building compiled Node.js add-ons in Rust via Node-API.
|
||||||
@@ -221,12 +206,6 @@ See [BUILDING.md] for instructions on how to build AFFiNE from source code.
|
|||||||
We welcome contributions from everyone.
|
We welcome contributions from everyone.
|
||||||
See [docs/contributing/tutorial.md](./docs/contributing/tutorial.md) for details.
|
See [docs/contributing/tutorial.md](./docs/contributing/tutorial.md) for details.
|
||||||
|
|
||||||
## Thanks
|
|
||||||
|
|
||||||
<a href="https://www.chromatic.com/"><img src="https://user-images.githubusercontent.com/321738/84662277-e3db4f80-af1b-11ea-88f5-91d67a5e59f6.png" width="153" height="30" alt="Chromatic" /></a>
|
|
||||||
|
|
||||||
Thanks to [Chromatic](https://www.chromatic.com/) for providing the visual testing platform that helps us review UI changes and catch visual regressions.
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
### Editions
|
### Editions
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
|
||||||
|
import { describe, expect, test } from 'vitest';
|
||||||
|
|
||||||
|
import { insertUrlTextSegments } from '../../../../blocks/database/src/properties/paste-url.js';
|
||||||
|
|
||||||
|
type InsertCall = {
|
||||||
|
range: {
|
||||||
|
index: number;
|
||||||
|
length: number;
|
||||||
|
};
|
||||||
|
text: string;
|
||||||
|
attributes?: AffineTextAttributes;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('insertUrlTextSegments', () => {
|
||||||
|
test('should replace selected text on first insert and append remaining segments', () => {
|
||||||
|
const insertCalls: InsertCall[] = [];
|
||||||
|
const selectionCalls: Array<{ index: number; length: number } | null> = [];
|
||||||
|
const inlineEditor = {
|
||||||
|
insertText: (
|
||||||
|
range: { index: number; length: number },
|
||||||
|
text: string,
|
||||||
|
attributes?: AffineTextAttributes
|
||||||
|
) => {
|
||||||
|
insertCalls.push({ range, text, attributes });
|
||||||
|
},
|
||||||
|
setInlineRange: (range: { index: number; length: number } | null) => {
|
||||||
|
selectionCalls.push(range);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const inlineRange = { index: 4, length: 6 };
|
||||||
|
const segments = [
|
||||||
|
{ text: 'hi - ' },
|
||||||
|
{ text: 'https://google.com', link: 'https://google.com' },
|
||||||
|
];
|
||||||
|
|
||||||
|
insertUrlTextSegments(inlineEditor, inlineRange, segments);
|
||||||
|
|
||||||
|
expect(insertCalls).toEqual([
|
||||||
|
{
|
||||||
|
range: { index: 4, length: 6 },
|
||||||
|
text: 'hi - ',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
range: { index: 9, length: 0 },
|
||||||
|
text: 'https://google.com',
|
||||||
|
attributes: {
|
||||||
|
link: 'https://google.com',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(selectionCalls).toEqual([{ index: 27, length: 0 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should keep insertion range length zero when there is no selected text', () => {
|
||||||
|
const insertCalls: InsertCall[] = [];
|
||||||
|
const selectionCalls: Array<{ index: number; length: number } | null> = [];
|
||||||
|
const inlineEditor = {
|
||||||
|
insertText: (
|
||||||
|
range: { index: number; length: number },
|
||||||
|
text: string,
|
||||||
|
attributes?: AffineTextAttributes
|
||||||
|
) => {
|
||||||
|
insertCalls.push({ range, text, attributes });
|
||||||
|
},
|
||||||
|
setInlineRange: (range: { index: number; length: number } | null) => {
|
||||||
|
selectionCalls.push(range);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const inlineRange = { index: 2, length: 0 };
|
||||||
|
const segments = [
|
||||||
|
{ text: 'prefix ' },
|
||||||
|
{ text: 'https://a.com', link: 'https://a.com' },
|
||||||
|
];
|
||||||
|
|
||||||
|
insertUrlTextSegments(inlineEditor, inlineRange, segments);
|
||||||
|
|
||||||
|
expect(insertCalls).toEqual([
|
||||||
|
{
|
||||||
|
range: { index: 2, length: 0 },
|
||||||
|
text: 'prefix ',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
range: { index: 9, length: 0 },
|
||||||
|
text: 'https://a.com',
|
||||||
|
attributes: {
|
||||||
|
link: 'https://a.com',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(selectionCalls).toEqual([{ index: 22, length: 0 }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -135,14 +135,10 @@ export class DatabaseBlockDataSource extends DataSourceBase {
|
|||||||
|
|
||||||
override featureFlags$: ReadonlySignal<DatabaseFlags> = computed(() => {
|
override featureFlags$: ReadonlySignal<DatabaseFlags> = computed(() => {
|
||||||
const featureFlagService = this.doc.get(FeatureFlagService);
|
const featureFlagService = this.doc.get(FeatureFlagService);
|
||||||
const enableNumberFormat = featureFlagService.getFlag(
|
|
||||||
'enable_database_number_formatting'
|
|
||||||
);
|
|
||||||
const enableTableVirtualScroll = featureFlagService.getFlag(
|
const enableTableVirtualScroll = featureFlagService.getFlag(
|
||||||
'enable_table_virtual_scroll'
|
'enable_table_virtual_scroll'
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
enable_number_formatting: enableNumberFormat ?? false,
|
|
||||||
enable_table_virtual_scroll: enableTableVirtualScroll ?? false,
|
enable_table_virtual_scroll: enableTableVirtualScroll ?? false,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import type {
|
||||||
|
AffineInlineEditor,
|
||||||
|
AffineTextAttributes,
|
||||||
|
} from '@blocksuite/affine-shared/types';
|
||||||
|
import {
|
||||||
|
splitTextByUrl,
|
||||||
|
type UrlTextSegment,
|
||||||
|
} from '@blocksuite/affine-shared/utils';
|
||||||
|
import type { InlineRange } from '@blocksuite/std/inline';
|
||||||
|
|
||||||
|
type UrlPasteInlineEditor = Pick<
|
||||||
|
AffineInlineEditor,
|
||||||
|
'insertText' | 'setInlineRange'
|
||||||
|
>;
|
||||||
|
|
||||||
|
export function analyzeTextForUrlPaste(text: string) {
|
||||||
|
const segments = splitTextByUrl(text);
|
||||||
|
const firstSegment = segments[0];
|
||||||
|
const singleUrl =
|
||||||
|
segments.length === 1 && firstSegment?.link && firstSegment.text === text
|
||||||
|
? firstSegment.link
|
||||||
|
: undefined;
|
||||||
|
return {
|
||||||
|
segments,
|
||||||
|
singleUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function insertUrlTextSegments(
|
||||||
|
inlineEditor: UrlPasteInlineEditor,
|
||||||
|
inlineRange: InlineRange,
|
||||||
|
segments: UrlTextSegment[]
|
||||||
|
) {
|
||||||
|
let index = inlineRange.index;
|
||||||
|
let replacedSelection = false;
|
||||||
|
segments.forEach(segment => {
|
||||||
|
if (!segment.text) return;
|
||||||
|
const attributes: AffineTextAttributes | undefined = segment.link
|
||||||
|
? { link: segment.link }
|
||||||
|
: undefined;
|
||||||
|
inlineEditor.insertText(
|
||||||
|
{
|
||||||
|
index,
|
||||||
|
length: replacedSelection ? 0 : inlineRange.length,
|
||||||
|
},
|
||||||
|
segment.text,
|
||||||
|
attributes
|
||||||
|
);
|
||||||
|
replacedSelection = true;
|
||||||
|
index += segment.text.length;
|
||||||
|
});
|
||||||
|
inlineEditor.setInlineRange({
|
||||||
|
index,
|
||||||
|
length: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -8,10 +8,7 @@ import type {
|
|||||||
AffineInlineEditor,
|
AffineInlineEditor,
|
||||||
AffineTextAttributes,
|
AffineTextAttributes,
|
||||||
} from '@blocksuite/affine-shared/types';
|
} from '@blocksuite/affine-shared/types';
|
||||||
import {
|
import { getViewportElement } from '@blocksuite/affine-shared/utils';
|
||||||
getViewportElement,
|
|
||||||
isValidUrl,
|
|
||||||
} from '@blocksuite/affine-shared/utils';
|
|
||||||
import {
|
import {
|
||||||
BaseCellRenderer,
|
BaseCellRenderer,
|
||||||
createFromBaseCellRenderer,
|
createFromBaseCellRenderer,
|
||||||
@@ -26,6 +23,7 @@ import { html } from 'lit/static-html.js';
|
|||||||
|
|
||||||
import { EditorHostKey } from '../../context/host-context.js';
|
import { EditorHostKey } from '../../context/host-context.js';
|
||||||
import type { DatabaseBlockComponent } from '../../database-block.js';
|
import type { DatabaseBlockComponent } from '../../database-block.js';
|
||||||
|
import { analyzeTextForUrlPaste, insertUrlTextSegments } from '../paste-url.js';
|
||||||
import {
|
import {
|
||||||
richTextCellStyle,
|
richTextCellStyle,
|
||||||
richTextContainerStyle,
|
richTextContainerStyle,
|
||||||
@@ -271,10 +269,13 @@ export class RichTextCell extends BaseCellRenderer<Text, string> {
|
|||||||
?.getData('text/plain')
|
?.getData('text/plain')
|
||||||
?.replace(/\r?\n|\r/g, '\n');
|
?.replace(/\r?\n|\r/g, '\n');
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
|
const { segments, singleUrl } = analyzeTextForUrlPaste(text);
|
||||||
|
|
||||||
if (isValidUrl(text)) {
|
if (singleUrl) {
|
||||||
const std = this.std;
|
const std = this.std;
|
||||||
const result = std?.getOptional(ParseDocUrlProvider)?.parseDocUrl(text);
|
const result = std
|
||||||
|
?.getOptional(ParseDocUrlProvider)
|
||||||
|
?.parseDocUrl(singleUrl);
|
||||||
if (result) {
|
if (result) {
|
||||||
const text = ' ';
|
const text = ' ';
|
||||||
inlineEditor.insertText(inlineRange, text, {
|
inlineEditor.insertText(inlineRange, text, {
|
||||||
@@ -300,22 +301,10 @@ export class RichTextCell extends BaseCellRenderer<Text, string> {
|
|||||||
segment: 'database',
|
segment: 'database',
|
||||||
parentFlavour: 'affine:database',
|
parentFlavour: 'affine:database',
|
||||||
});
|
});
|
||||||
} else {
|
return;
|
||||||
inlineEditor.insertText(inlineRange, text, {
|
|
||||||
link: text,
|
|
||||||
});
|
|
||||||
inlineEditor.setInlineRange({
|
|
||||||
index: inlineRange.index + text.length,
|
|
||||||
length: 0,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
inlineEditor.insertText(inlineRange, text);
|
|
||||||
inlineEditor.setInlineRange({
|
|
||||||
index: inlineRange.index + text.length,
|
|
||||||
length: 0,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
insertUrlTextSegments(inlineEditor, inlineRange, segments);
|
||||||
};
|
};
|
||||||
|
|
||||||
override connectedCallback() {
|
override connectedCallback() {
|
||||||
|
|||||||
@@ -4,10 +4,7 @@ import {
|
|||||||
ParseDocUrlProvider,
|
ParseDocUrlProvider,
|
||||||
TelemetryProvider,
|
TelemetryProvider,
|
||||||
} from '@blocksuite/affine-shared/services';
|
} from '@blocksuite/affine-shared/services';
|
||||||
import {
|
import { getViewportElement } from '@blocksuite/affine-shared/utils';
|
||||||
getViewportElement,
|
|
||||||
isValidUrl,
|
|
||||||
} from '@blocksuite/affine-shared/utils';
|
|
||||||
import { BaseCellRenderer } from '@blocksuite/data-view';
|
import { BaseCellRenderer } from '@blocksuite/data-view';
|
||||||
import { IS_MAC } from '@blocksuite/global/env';
|
import { IS_MAC } from '@blocksuite/global/env';
|
||||||
import { LinkedPageIcon } from '@blocksuite/icons/lit';
|
import { LinkedPageIcon } from '@blocksuite/icons/lit';
|
||||||
@@ -20,6 +17,7 @@ import { html } from 'lit/static-html.js';
|
|||||||
import { EditorHostKey } from '../../context/host-context.js';
|
import { EditorHostKey } from '../../context/host-context.js';
|
||||||
import type { DatabaseBlockComponent } from '../../database-block.js';
|
import type { DatabaseBlockComponent } from '../../database-block.js';
|
||||||
import { getSingleDocIdFromText } from '../../utils/title-doc.js';
|
import { getSingleDocIdFromText } from '../../utils/title-doc.js';
|
||||||
|
import { analyzeTextForUrlPaste, insertUrlTextSegments } from '../paste-url.js';
|
||||||
import {
|
import {
|
||||||
headerAreaIconStyle,
|
headerAreaIconStyle,
|
||||||
titleCellStyle,
|
titleCellStyle,
|
||||||
@@ -95,7 +93,9 @@ export class HeaderAreaTextCell extends BaseCellRenderer<Text, string> {
|
|||||||
private readonly _onPaste = (e: ClipboardEvent) => {
|
private readonly _onPaste = (e: ClipboardEvent) => {
|
||||||
const inlineEditor = this.inlineEditor;
|
const inlineEditor = this.inlineEditor;
|
||||||
const inlineRange = inlineEditor?.getInlineRange();
|
const inlineRange = inlineEditor?.getInlineRange();
|
||||||
if (!inlineRange) return;
|
if (!inlineEditor || !inlineRange) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
if (e.clipboardData) {
|
if (e.clipboardData) {
|
||||||
try {
|
try {
|
||||||
const getDeltas = (snapshot: BlockSnapshot): DeltaInsert[] => {
|
const getDeltas = (snapshot: BlockSnapshot): DeltaInsert[] => {
|
||||||
@@ -121,14 +121,15 @@ export class HeaderAreaTextCell extends BaseCellRenderer<Text, string> {
|
|||||||
?.getData('text/plain')
|
?.getData('text/plain')
|
||||||
?.replace(/\r?\n|\r/g, '\n');
|
?.replace(/\r?\n|\r/g, '\n');
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
e.preventDefault();
|
const { segments, singleUrl } = analyzeTextForUrlPaste(text);
|
||||||
e.stopPropagation();
|
if (singleUrl) {
|
||||||
if (isValidUrl(text)) {
|
|
||||||
const std = this.std;
|
const std = this.std;
|
||||||
const result = std?.getOptional(ParseDocUrlProvider)?.parseDocUrl(text);
|
const result = std
|
||||||
|
?.getOptional(ParseDocUrlProvider)
|
||||||
|
?.parseDocUrl(singleUrl);
|
||||||
if (result) {
|
if (result) {
|
||||||
const text = ' ';
|
const text = ' ';
|
||||||
inlineEditor?.insertText(inlineRange, text, {
|
inlineEditor.insertText(inlineRange, text, {
|
||||||
reference: {
|
reference: {
|
||||||
type: 'LinkedPage',
|
type: 'LinkedPage',
|
||||||
pageId: result.docId,
|
pageId: result.docId,
|
||||||
@@ -139,7 +140,7 @@ export class HeaderAreaTextCell extends BaseCellRenderer<Text, string> {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
inlineEditor?.setInlineRange({
|
inlineEditor.setInlineRange({
|
||||||
index: inlineRange.index + text.length,
|
index: inlineRange.index + text.length,
|
||||||
length: 0,
|
length: 0,
|
||||||
});
|
});
|
||||||
@@ -151,22 +152,10 @@ export class HeaderAreaTextCell extends BaseCellRenderer<Text, string> {
|
|||||||
segment: 'database',
|
segment: 'database',
|
||||||
parentFlavour: 'affine:database',
|
parentFlavour: 'affine:database',
|
||||||
});
|
});
|
||||||
} else {
|
return;
|
||||||
inlineEditor?.insertText(inlineRange, text, {
|
|
||||||
link: text,
|
|
||||||
});
|
|
||||||
inlineEditor?.setInlineRange({
|
|
||||||
index: inlineRange.index + text.length,
|
|
||||||
length: 0,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
inlineEditor?.insertText(inlineRange, text);
|
|
||||||
inlineEditor?.setInlineRange({
|
|
||||||
index: inlineRange.index + text.length,
|
|
||||||
length: 0,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
insertUrlTextSegments(inlineEditor, inlineRange, segments);
|
||||||
};
|
};
|
||||||
|
|
||||||
insertDelta = (delta: DeltaInsert) => {
|
insertDelta = (delta: DeltaInsert) => {
|
||||||
@@ -240,7 +229,8 @@ export class HeaderAreaTextCell extends BaseCellRenderer<Text, string> {
|
|||||||
this.disposables.addFromEvent(
|
this.disposables.addFromEvent(
|
||||||
this.richText.value,
|
this.richText.value,
|
||||||
'paste',
|
'paste',
|
||||||
this._onPaste
|
this._onPaste,
|
||||||
|
true
|
||||||
);
|
);
|
||||||
const inlineEditor = this.inlineEditor;
|
const inlineEditor = this.inlineEditor;
|
||||||
if (inlineEditor) {
|
if (inlineEditor) {
|
||||||
|
|||||||
517
blocksuite/affine/data-view/src/__tests__/kanban.unit.spec.ts
Normal file
517
blocksuite/affine/data-view/src/__tests__/kanban.unit.spec.ts
Normal file
@@ -0,0 +1,517 @@
|
|||||||
|
import { signal } from '@preact/signals-core';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import type { GroupBy } from '../core/common/types.js';
|
||||||
|
import type { DataSource } from '../core/data-source/base.js';
|
||||||
|
import { DetailSelection } from '../core/detail/selection.js';
|
||||||
|
import { groupByMatchers } from '../core/group-by/define.js';
|
||||||
|
import { t } from '../core/logical/type-presets.js';
|
||||||
|
import type { DataViewCellLifeCycle } from '../core/property/index.js';
|
||||||
|
import { checkboxPropertyModelConfig } from '../property-presets/checkbox/define.js';
|
||||||
|
import { multiSelectPropertyModelConfig } from '../property-presets/multi-select/define.js';
|
||||||
|
import { selectPropertyModelConfig } from '../property-presets/select/define.js';
|
||||||
|
import { textPropertyModelConfig } from '../property-presets/text/define.js';
|
||||||
|
import {
|
||||||
|
canGroupable,
|
||||||
|
ensureKanbanGroupColumn,
|
||||||
|
pickKanbanGroupColumn,
|
||||||
|
resolveKanbanGroupBy,
|
||||||
|
} from '../view-presets/kanban/group-by-utils.js';
|
||||||
|
import { materializeKanbanColumns } from '../view-presets/kanban/kanban-view-manager.js';
|
||||||
|
import type { KanbanCard } from '../view-presets/kanban/pc/card.js';
|
||||||
|
import { KanbanDragController } from '../view-presets/kanban/pc/controller/drag.js';
|
||||||
|
import type { KanbanGroup } from '../view-presets/kanban/pc/group.js';
|
||||||
|
|
||||||
|
type Column = {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
data?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TestPropertyMeta = {
|
||||||
|
type: string;
|
||||||
|
config: {
|
||||||
|
kanbanGroup?: {
|
||||||
|
enabled: boolean;
|
||||||
|
mutable?: boolean;
|
||||||
|
};
|
||||||
|
propertyData: {
|
||||||
|
default: () => Record<string, unknown>;
|
||||||
|
};
|
||||||
|
jsonValue: {
|
||||||
|
type: (options: {
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
dataSource: DataSource;
|
||||||
|
}) => unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type MockDataSource = {
|
||||||
|
properties$: ReturnType<typeof signal<string[]>>;
|
||||||
|
provider: {
|
||||||
|
getAll: () => Map<unknown, unknown>;
|
||||||
|
};
|
||||||
|
serviceGetOrCreate: (key: unknown, create: () => unknown) => unknown;
|
||||||
|
propertyTypeGet: (propertyId: string) => string | undefined;
|
||||||
|
propertyMetaGet: (type: string) => TestPropertyMeta | undefined;
|
||||||
|
propertyDataGet: (propertyId: string) => Record<string, unknown>;
|
||||||
|
propertyDataTypeGet: (propertyId: string) => unknown;
|
||||||
|
propertyAdd: (
|
||||||
|
_position: unknown,
|
||||||
|
ops?: {
|
||||||
|
type?: string;
|
||||||
|
}
|
||||||
|
) => string;
|
||||||
|
propertyDataSet: (propertyId: string, data: Record<string, unknown>) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const asDataSource = (dataSource: object): DataSource =>
|
||||||
|
dataSource as DataSource;
|
||||||
|
|
||||||
|
const toTestMeta = <TData extends Record<string, unknown>>(
|
||||||
|
type: string,
|
||||||
|
config: {
|
||||||
|
kanbanGroup?: {
|
||||||
|
enabled: boolean;
|
||||||
|
mutable?: boolean;
|
||||||
|
};
|
||||||
|
propertyData: {
|
||||||
|
default: () => TData;
|
||||||
|
};
|
||||||
|
jsonValue: {
|
||||||
|
type: (options: { data: TData; dataSource: DataSource }) => unknown;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
): TestPropertyMeta => ({
|
||||||
|
type,
|
||||||
|
config: {
|
||||||
|
kanbanGroup: config.kanbanGroup,
|
||||||
|
propertyData: {
|
||||||
|
default: () => config.propertyData.default(),
|
||||||
|
},
|
||||||
|
jsonValue: {
|
||||||
|
type: ({ data, dataSource }) =>
|
||||||
|
config.jsonValue.type({
|
||||||
|
data: data as TData,
|
||||||
|
dataSource,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const immutableBooleanMeta = toTestMeta('immutable-boolean', {
|
||||||
|
...checkboxPropertyModelConfig.config,
|
||||||
|
kanbanGroup: {
|
||||||
|
enabled: true,
|
||||||
|
mutable: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const createMockDataSource = (columns: Column[]): MockDataSource => {
|
||||||
|
const properties$ = signal(columns.map(column => column.id));
|
||||||
|
const typeById = new Map(columns.map(column => [column.id, column.type]));
|
||||||
|
const dataById = new Map(
|
||||||
|
columns.map(column => [column.id, column.data ?? {}])
|
||||||
|
);
|
||||||
|
const services = new Map<unknown, unknown>();
|
||||||
|
|
||||||
|
const metaEntries: Array<[string, TestPropertyMeta]> = [
|
||||||
|
[
|
||||||
|
checkboxPropertyModelConfig.type,
|
||||||
|
toTestMeta(
|
||||||
|
checkboxPropertyModelConfig.type,
|
||||||
|
checkboxPropertyModelConfig.config
|
||||||
|
),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
selectPropertyModelConfig.type,
|
||||||
|
toTestMeta(
|
||||||
|
selectPropertyModelConfig.type,
|
||||||
|
selectPropertyModelConfig.config
|
||||||
|
),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
multiSelectPropertyModelConfig.type,
|
||||||
|
toTestMeta(
|
||||||
|
multiSelectPropertyModelConfig.type,
|
||||||
|
multiSelectPropertyModelConfig.config
|
||||||
|
),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
textPropertyModelConfig.type,
|
||||||
|
toTestMeta(textPropertyModelConfig.type, textPropertyModelConfig.config),
|
||||||
|
],
|
||||||
|
[immutableBooleanMeta.type, immutableBooleanMeta],
|
||||||
|
];
|
||||||
|
const metaByType = new Map(metaEntries);
|
||||||
|
|
||||||
|
const asRecord = (value: unknown): Record<string, unknown> =>
|
||||||
|
typeof value === 'object' && value != null
|
||||||
|
? (value as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
|
||||||
|
let autoColumnId = 0;
|
||||||
|
|
||||||
|
const dataSource = {
|
||||||
|
properties$,
|
||||||
|
provider: {
|
||||||
|
getAll: () => new Map<unknown, unknown>(),
|
||||||
|
},
|
||||||
|
serviceGetOrCreate: (key: unknown, create: () => unknown) => {
|
||||||
|
if (!services.has(key)) {
|
||||||
|
services.set(key, create());
|
||||||
|
}
|
||||||
|
return services.get(key);
|
||||||
|
},
|
||||||
|
propertyTypeGet: (propertyId: string) => typeById.get(propertyId),
|
||||||
|
propertyMetaGet: (type: string) => metaByType.get(type),
|
||||||
|
propertyDataGet: (propertyId: string) => asRecord(dataById.get(propertyId)),
|
||||||
|
propertyDataTypeGet: (propertyId: string) => {
|
||||||
|
const type = typeById.get(propertyId);
|
||||||
|
if (!type) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const meta = metaByType.get(type);
|
||||||
|
if (!meta) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return meta.config.jsonValue.type({
|
||||||
|
data: asRecord(dataById.get(propertyId)),
|
||||||
|
dataSource: asDataSource(dataSource),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
propertyAdd: (
|
||||||
|
_position: unknown,
|
||||||
|
ops?: {
|
||||||
|
type?: string;
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
const type = ops?.type ?? selectPropertyModelConfig.type;
|
||||||
|
const id = `auto-${++autoColumnId}`;
|
||||||
|
const meta = metaByType.get(type);
|
||||||
|
const data = meta?.config.propertyData.default() ?? {};
|
||||||
|
|
||||||
|
typeById.set(id, type);
|
||||||
|
dataById.set(id, data);
|
||||||
|
properties$.value = [...properties$.value, id];
|
||||||
|
return id;
|
||||||
|
},
|
||||||
|
propertyDataSet: (propertyId: string, data: Record<string, unknown>) => {
|
||||||
|
dataById.set(propertyId, data);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return dataSource;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createDragController = () => {
|
||||||
|
type DragLogic = ConstructorParameters<typeof KanbanDragController>[0];
|
||||||
|
return new KanbanDragController({} as DragLogic);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('kanban', () => {
|
||||||
|
describe('group-by define', () => {
|
||||||
|
it('boolean group should not include ungroup bucket', () => {
|
||||||
|
const booleanGroup = groupByMatchers.find(
|
||||||
|
group => group.name === 'boolean'
|
||||||
|
);
|
||||||
|
expect(booleanGroup).toBeDefined();
|
||||||
|
|
||||||
|
const keys = booleanGroup!
|
||||||
|
.defaultKeys(t.boolean.instance())
|
||||||
|
.map(group => group.key);
|
||||||
|
|
||||||
|
expect(keys).toEqual(['true', 'false']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('boolean group should fallback invalid values to false bucket', () => {
|
||||||
|
const booleanGroup = groupByMatchers.find(
|
||||||
|
group => group.name === 'boolean'
|
||||||
|
);
|
||||||
|
expect(booleanGroup).toBeDefined();
|
||||||
|
|
||||||
|
const groups = booleanGroup!.valuesGroup(undefined, t.boolean.instance());
|
||||||
|
expect(groups).toEqual([{ key: 'false', value: false }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('columns materialization', () => {
|
||||||
|
it('appends missing properties while preserving existing order and state', () => {
|
||||||
|
const columns = [{ id: 'status', hide: true }, { id: 'title' }];
|
||||||
|
|
||||||
|
const next = materializeKanbanColumns(columns, [
|
||||||
|
'title',
|
||||||
|
'status',
|
||||||
|
'date',
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(next).toEqual([
|
||||||
|
{ id: 'status', hide: true },
|
||||||
|
{ id: 'title' },
|
||||||
|
{ id: 'date' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops stale columns that no longer exist in data source', () => {
|
||||||
|
const columns = [{ id: 'title' }, { id: 'removed', hide: true }];
|
||||||
|
|
||||||
|
const next = materializeKanbanColumns(columns, ['title']);
|
||||||
|
|
||||||
|
expect(next).toEqual([{ id: 'title' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns original reference when columns are already materialized', () => {
|
||||||
|
const columns = [{ id: 'title' }, { id: 'status', hide: true }];
|
||||||
|
|
||||||
|
const next = materializeKanbanColumns(columns, ['title', 'status']);
|
||||||
|
|
||||||
|
expect(next).toBe(columns);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('drag indicator', () => {
|
||||||
|
it('shows drop preview when insert position exists', () => {
|
||||||
|
const controller = createDragController();
|
||||||
|
const position = {
|
||||||
|
group: {} as KanbanGroup,
|
||||||
|
position: 'end' as const,
|
||||||
|
};
|
||||||
|
controller.getInsertPosition = vi.fn().mockReturnValue(position);
|
||||||
|
|
||||||
|
const displaySpy = vi.spyOn(controller.dropPreview, 'display');
|
||||||
|
const removeSpy = vi.spyOn(controller.dropPreview, 'remove');
|
||||||
|
|
||||||
|
const result = controller.showIndicator({} as MouseEvent, undefined);
|
||||||
|
|
||||||
|
expect(result).toBe(position);
|
||||||
|
expect(displaySpy).toHaveBeenCalledWith(
|
||||||
|
position.group,
|
||||||
|
undefined,
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
expect(removeSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes drop preview when insert position does not exist', () => {
|
||||||
|
const controller = createDragController();
|
||||||
|
controller.getInsertPosition = vi.fn().mockReturnValue(undefined);
|
||||||
|
|
||||||
|
const displaySpy = vi.spyOn(controller.dropPreview, 'display');
|
||||||
|
const removeSpy = vi.spyOn(controller.dropPreview, 'remove');
|
||||||
|
|
||||||
|
const result = controller.showIndicator({} as MouseEvent, undefined);
|
||||||
|
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
expect(displaySpy).not.toHaveBeenCalled();
|
||||||
|
expect(removeSpy).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forwards hovered card to drop preview for precise insertion cursor', () => {
|
||||||
|
const controller = createDragController();
|
||||||
|
const hoveredCard = document.createElement(
|
||||||
|
'affine-data-view-kanban-card'
|
||||||
|
) as KanbanCard;
|
||||||
|
const positionCard = document.createElement(
|
||||||
|
'affine-data-view-kanban-card'
|
||||||
|
) as KanbanCard;
|
||||||
|
const position = {
|
||||||
|
group: {} as KanbanGroup,
|
||||||
|
card: positionCard,
|
||||||
|
position: { before: true, id: 'card-id' } as const,
|
||||||
|
};
|
||||||
|
controller.getInsertPosition = vi.fn().mockReturnValue(position);
|
||||||
|
|
||||||
|
const displaySpy = vi.spyOn(controller.dropPreview, 'display');
|
||||||
|
|
||||||
|
controller.showIndicator({} as MouseEvent, hoveredCard);
|
||||||
|
|
||||||
|
expect(displaySpy).toHaveBeenCalledWith(
|
||||||
|
position.group,
|
||||||
|
hoveredCard,
|
||||||
|
position.card
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('group-by utils', () => {
|
||||||
|
it('allows only kanban-enabled property types to group', () => {
|
||||||
|
const dataSource = createMockDataSource([
|
||||||
|
{ id: 'text', type: textPropertyModelConfig.type },
|
||||||
|
{ id: 'select', type: selectPropertyModelConfig.type },
|
||||||
|
{ id: 'multi-select', type: multiSelectPropertyModelConfig.type },
|
||||||
|
{ id: 'checkbox', type: checkboxPropertyModelConfig.type },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(canGroupable(asDataSource(dataSource), 'text')).toBe(false);
|
||||||
|
expect(canGroupable(asDataSource(dataSource), 'select')).toBe(true);
|
||||||
|
expect(canGroupable(asDataSource(dataSource), 'multi-select')).toBe(true);
|
||||||
|
expect(canGroupable(asDataSource(dataSource), 'checkbox')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers mutable group column over immutable ones', () => {
|
||||||
|
const dataSource = createMockDataSource([
|
||||||
|
{
|
||||||
|
id: 'immutable-bool',
|
||||||
|
type: 'immutable-boolean',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'checkbox',
|
||||||
|
type: checkboxPropertyModelConfig.type,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(pickKanbanGroupColumn(asDataSource(dataSource))).toBe('checkbox');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates default status select column when no groupable column exists', () => {
|
||||||
|
const dataSource = createMockDataSource([
|
||||||
|
{
|
||||||
|
id: 'text',
|
||||||
|
type: textPropertyModelConfig.type,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const statusColumnId = ensureKanbanGroupColumn(asDataSource(dataSource));
|
||||||
|
|
||||||
|
expect(statusColumnId).toBeTruthy();
|
||||||
|
expect(dataSource.propertyTypeGet(statusColumnId!)).toBe(
|
||||||
|
selectPropertyModelConfig.type
|
||||||
|
);
|
||||||
|
const options =
|
||||||
|
(
|
||||||
|
dataSource.propertyDataGet(statusColumnId!) as {
|
||||||
|
options?: { value: string }[];
|
||||||
|
}
|
||||||
|
).options ?? [];
|
||||||
|
expect(options.map(option => option.value)).toEqual([
|
||||||
|
'Todo',
|
||||||
|
'In Progress',
|
||||||
|
'Done',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults hideEmpty to true for non-option groups', () => {
|
||||||
|
const dataSource = createMockDataSource([
|
||||||
|
{
|
||||||
|
id: 'checkbox',
|
||||||
|
type: checkboxPropertyModelConfig.type,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const next = resolveKanbanGroupBy(asDataSource(dataSource));
|
||||||
|
expect(next?.columnId).toBe('checkbox');
|
||||||
|
expect(next?.hideEmpty).toBe(true);
|
||||||
|
expect(next?.name).toBe('boolean');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults hideEmpty to false for select grouping', () => {
|
||||||
|
const dataSource = createMockDataSource([
|
||||||
|
{
|
||||||
|
id: 'select',
|
||||||
|
type: selectPropertyModelConfig.type,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const next = resolveKanbanGroupBy(asDataSource(dataSource));
|
||||||
|
expect(next?.columnId).toBe('select');
|
||||||
|
expect(next?.hideEmpty).toBe(false);
|
||||||
|
expect(next?.name).toBe('select');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves sort and explicit hideEmpty when resolving groupBy', () => {
|
||||||
|
const dataSource = createMockDataSource([
|
||||||
|
{
|
||||||
|
id: 'checkbox',
|
||||||
|
type: checkboxPropertyModelConfig.type,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const current: GroupBy = {
|
||||||
|
type: 'groupBy',
|
||||||
|
columnId: 'checkbox',
|
||||||
|
name: 'boolean',
|
||||||
|
sort: { desc: true },
|
||||||
|
hideEmpty: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const next = resolveKanbanGroupBy(asDataSource(dataSource), current);
|
||||||
|
|
||||||
|
expect(next?.columnId).toBe('checkbox');
|
||||||
|
expect(next?.sort).toEqual({ desc: true });
|
||||||
|
expect(next?.hideEmpty).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces current non-groupable column with a valid kanban column', () => {
|
||||||
|
const dataSource = createMockDataSource([
|
||||||
|
{ id: 'text', type: textPropertyModelConfig.type },
|
||||||
|
{ id: 'checkbox', type: checkboxPropertyModelConfig.type },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const next = resolveKanbanGroupBy(asDataSource(dataSource), {
|
||||||
|
type: 'groupBy',
|
||||||
|
columnId: 'text',
|
||||||
|
name: 'text',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next?.columnId).toBe('checkbox');
|
||||||
|
expect(next?.name).toBe('boolean');
|
||||||
|
expect(next?.hideEmpty).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('detail selection', () => {
|
||||||
|
it('should avoid recursive selection update when exiting select edit mode', () => {
|
||||||
|
vi.stubGlobal('requestAnimationFrame', ((cb: FrameRequestCallback) => {
|
||||||
|
cb(0);
|
||||||
|
return 0;
|
||||||
|
}) as typeof requestAnimationFrame);
|
||||||
|
try {
|
||||||
|
let selection: DetailSelection;
|
||||||
|
let beforeExitCalls = 0;
|
||||||
|
|
||||||
|
const cell = {
|
||||||
|
beforeEnterEditMode: () => true,
|
||||||
|
beforeExitEditingMode: () => {
|
||||||
|
beforeExitCalls += 1;
|
||||||
|
selection.selection = {
|
||||||
|
propertyId: 'status',
|
||||||
|
isEditing: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
afterEnterEditingMode: () => {},
|
||||||
|
focusCell: () => true,
|
||||||
|
blurCell: () => true,
|
||||||
|
forceUpdate: () => {},
|
||||||
|
} satisfies DataViewCellLifeCycle;
|
||||||
|
|
||||||
|
const field = {
|
||||||
|
isFocus$: signal(false),
|
||||||
|
isEditing$: signal(false),
|
||||||
|
cell,
|
||||||
|
focus: () => {},
|
||||||
|
blur: () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const detail = {
|
||||||
|
querySelector: () => field,
|
||||||
|
};
|
||||||
|
|
||||||
|
selection = new DetailSelection(detail);
|
||||||
|
selection.selection = {
|
||||||
|
propertyId: 'status',
|
||||||
|
isEditing: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
selection.selection = {
|
||||||
|
propertyId: 'status',
|
||||||
|
isEditing: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(beforeExitCalls).toBe(1);
|
||||||
|
expect(field.isEditing$.value).toBe(false);
|
||||||
|
} finally {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import { describe, expect, test } from 'vitest';
|
|
||||||
|
|
||||||
import { mobileEffects } from '../view-presets/table/mobile/effect.js';
|
|
||||||
import type { MobileTableGroup } from '../view-presets/table/mobile/group.js';
|
|
||||||
import { pcEffects } from '../view-presets/table/pc/effect.js';
|
|
||||||
import type { TableGroup } from '../view-presets/table/pc/group.js';
|
|
||||||
|
|
||||||
/** @vitest-environment happy-dom */
|
|
||||||
|
|
||||||
describe('TableGroup', () => {
|
|
||||||
test('toggle collapse on pc', () => {
|
|
||||||
pcEffects();
|
|
||||||
const group = document.createElement(
|
|
||||||
'affine-data-view-table-group'
|
|
||||||
) as TableGroup;
|
|
||||||
|
|
||||||
expect(group.collapsed$.value).toBe(false);
|
|
||||||
(group as any)._toggleCollapse();
|
|
||||||
expect(group.collapsed$.value).toBe(true);
|
|
||||||
(group as any)._toggleCollapse();
|
|
||||||
expect(group.collapsed$.value).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('toggle collapse on mobile', () => {
|
|
||||||
mobileEffects();
|
|
||||||
const group = document.createElement(
|
|
||||||
'mobile-table-group'
|
|
||||||
) as MobileTableGroup;
|
|
||||||
|
|
||||||
expect(group.collapsed$.value).toBe(false);
|
|
||||||
(group as any)._toggleCollapse();
|
|
||||||
expect(group.collapsed$.value).toBe(true);
|
|
||||||
(group as any)._toggleCollapse();
|
|
||||||
expect(group.collapsed$.value).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
101
blocksuite/affine/data-view/src/__tests__/table.unit.spec.ts
Normal file
101
blocksuite/affine/data-view/src/__tests__/table.unit.spec.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { describe, expect, test } from 'vitest';
|
||||||
|
|
||||||
|
import { numberFormats } from '../property-presets/number/utils/formats.js';
|
||||||
|
import {
|
||||||
|
formatNumber,
|
||||||
|
NumberFormatSchema,
|
||||||
|
parseNumber,
|
||||||
|
} from '../property-presets/number/utils/formatter.js';
|
||||||
|
import { mobileEffects } from '../view-presets/table/mobile/effect.js';
|
||||||
|
import type { MobileTableGroup } from '../view-presets/table/mobile/group.js';
|
||||||
|
import { pcEffects } from '../view-presets/table/pc/effect.js';
|
||||||
|
import type { TableGroup } from '../view-presets/table/pc/group.js';
|
||||||
|
|
||||||
|
/** @vitest-environment happy-dom */
|
||||||
|
|
||||||
|
describe('TableGroup', () => {
|
||||||
|
test('toggle collapse on pc', () => {
|
||||||
|
pcEffects();
|
||||||
|
const group = document.createElement(
|
||||||
|
'affine-data-view-table-group'
|
||||||
|
) as TableGroup;
|
||||||
|
|
||||||
|
expect(group.collapsed$.value).toBe(false);
|
||||||
|
(group as any)._toggleCollapse();
|
||||||
|
expect(group.collapsed$.value).toBe(true);
|
||||||
|
(group as any)._toggleCollapse();
|
||||||
|
expect(group.collapsed$.value).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toggle collapse on mobile', () => {
|
||||||
|
mobileEffects();
|
||||||
|
const group = document.createElement(
|
||||||
|
'mobile-table-group'
|
||||||
|
) as MobileTableGroup;
|
||||||
|
|
||||||
|
expect(group.collapsed$.value).toBe(false);
|
||||||
|
(group as any)._toggleCollapse();
|
||||||
|
expect(group.collapsed$.value).toBe(true);
|
||||||
|
(group as any)._toggleCollapse();
|
||||||
|
expect(group.collapsed$.value).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('number formatter', () => {
|
||||||
|
test('number format menu should expose all schema formats', () => {
|
||||||
|
const menuFormats = numberFormats.map(format => format.type);
|
||||||
|
const schemaFormats = NumberFormatSchema.options;
|
||||||
|
|
||||||
|
expect(new Set(menuFormats)).toEqual(new Set(schemaFormats));
|
||||||
|
expect(menuFormats).toHaveLength(schemaFormats.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formats grouped decimal numbers with Intl grouping rules', () => {
|
||||||
|
const value = 11451.4;
|
||||||
|
const decimals = 1;
|
||||||
|
const expected = new Intl.NumberFormat(navigator.language, {
|
||||||
|
style: 'decimal',
|
||||||
|
useGrouping: true,
|
||||||
|
minimumFractionDigits: decimals,
|
||||||
|
maximumFractionDigits: decimals,
|
||||||
|
}).format(value);
|
||||||
|
|
||||||
|
expect(formatNumber(value, 'numberWithCommas', decimals)).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formats percent values with Intl percent rules', () => {
|
||||||
|
const value = 0.1234;
|
||||||
|
const decimals = 2;
|
||||||
|
const expected = new Intl.NumberFormat(navigator.language, {
|
||||||
|
style: 'percent',
|
||||||
|
useGrouping: false,
|
||||||
|
minimumFractionDigits: decimals,
|
||||||
|
maximumFractionDigits: decimals,
|
||||||
|
}).format(value);
|
||||||
|
|
||||||
|
expect(formatNumber(value, 'percent', decimals)).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formats currency values with Intl currency rules', () => {
|
||||||
|
const value = 11451.4;
|
||||||
|
const expected = new Intl.NumberFormat(navigator.language, {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'USD',
|
||||||
|
currencyDisplay: 'symbol',
|
||||||
|
}).format(value);
|
||||||
|
|
||||||
|
expect(formatNumber(value, 'currencyUSD')).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parses grouped number string pasted from clipboard', () => {
|
||||||
|
expect(parseNumber('11,451.4')).toBe(11451.4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps regular decimal parsing', () => {
|
||||||
|
expect(parseNumber('123.45')).toBe(123.45);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('supports comma as decimal separator in locale-specific input', () => {
|
||||||
|
expect(parseNumber('11451,4', ',')).toBe(11451.4);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -22,7 +22,6 @@ import { html } from 'lit/static-html.js';
|
|||||||
import { dataViewCommonStyle } from './common/css-variable.js';
|
import { dataViewCommonStyle } from './common/css-variable.js';
|
||||||
import type { DataSource } from './data-source/index.js';
|
import type { DataSource } from './data-source/index.js';
|
||||||
import type { DataViewSelection } from './types.js';
|
import type { DataViewSelection } from './types.js';
|
||||||
import { cacheComputed } from './utils/cache.js';
|
|
||||||
import { renderUniLit } from './utils/uni-component/index.js';
|
import { renderUniLit } from './utils/uni-component/index.js';
|
||||||
import type { DataViewUILogicBase } from './view/data-view-base.js';
|
import type { DataViewUILogicBase } from './view/data-view-base.js';
|
||||||
import type { SingleView } from './view-manager/single-view.js';
|
import type { SingleView } from './view-manager/single-view.js';
|
||||||
@@ -75,12 +74,38 @@ export class DataViewRootUILogic {
|
|||||||
|
|
||||||
return new (logic(view))(this, view);
|
return new (logic(view))(this, view);
|
||||||
}
|
}
|
||||||
private readonly views$ = cacheComputed(this.viewManager.views$, viewId =>
|
private readonly _viewsCache = new Map<
|
||||||
this.createDataViewUILogic(viewId)
|
string,
|
||||||
);
|
{ mode: string; logic: DataViewUILogicBase }
|
||||||
|
>();
|
||||||
|
|
||||||
|
private readonly views$ = computed(() => {
|
||||||
|
const viewDataList = this.dataSource.viewDataList$.value;
|
||||||
|
const validIds = new Set(viewDataList.map(viewData => viewData.id));
|
||||||
|
|
||||||
|
for (const cachedId of this._viewsCache.keys()) {
|
||||||
|
if (!validIds.has(cachedId)) {
|
||||||
|
this._viewsCache.delete(cachedId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return viewDataList.map(viewData => {
|
||||||
|
const cached = this._viewsCache.get(viewData.id);
|
||||||
|
if (cached && cached.mode === viewData.mode) {
|
||||||
|
return cached.logic;
|
||||||
|
}
|
||||||
|
const logic = this.createDataViewUILogic(viewData.id);
|
||||||
|
this._viewsCache.set(viewData.id, {
|
||||||
|
mode: viewData.mode,
|
||||||
|
logic,
|
||||||
|
});
|
||||||
|
return logic;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
private readonly viewsMap$ = computed(() => {
|
private readonly viewsMap$ = computed(() => {
|
||||||
return Object.fromEntries(
|
return Object.fromEntries(
|
||||||
this.views$.list.value.map(logic => [logic.view.id, logic])
|
this.views$.value.map(logic => [logic.view.id, logic])
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
private readonly _uiRef = signal<DataViewRootUI>();
|
private readonly _uiRef = signal<DataViewRootUI>();
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import type { KanbanCardSelection } from '../../view-presets';
|
import type { KanbanCardSelection } from '../../view-presets';
|
||||||
import type { KanbanCard } from '../../view-presets/kanban/pc/card.js';
|
import type { KanbanCard } from '../../view-presets/kanban/pc/card.js';
|
||||||
import { KanbanCell } from '../../view-presets/kanban/pc/cell.js';
|
import { KanbanCell } from '../../view-presets/kanban/pc/cell.js';
|
||||||
import type { RecordDetail } from './detail.js';
|
|
||||||
import { RecordField } from './field.js';
|
import { RecordField } from './field.js';
|
||||||
|
|
||||||
type DetailViewSelection = {
|
type DetailViewSelection = {
|
||||||
@@ -9,16 +8,39 @@ type DetailViewSelection = {
|
|||||||
isEditing: boolean;
|
isEditing: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type DetailSelectionHost = {
|
||||||
|
querySelector: (selector: string) => unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSameDetailSelection = (
|
||||||
|
current?: DetailViewSelection,
|
||||||
|
next?: DetailViewSelection
|
||||||
|
) => {
|
||||||
|
if (!current && !next) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!current || !next) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
current.propertyId === next.propertyId &&
|
||||||
|
current.isEditing === next.isEditing
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export class DetailSelection {
|
export class DetailSelection {
|
||||||
_selection?: DetailViewSelection;
|
_selection?: DetailViewSelection;
|
||||||
|
|
||||||
onSelect = (selection?: DetailViewSelection) => {
|
onSelect = (selection?: DetailViewSelection) => {
|
||||||
|
if (isSameDetailSelection(this._selection, selection)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const old = this._selection;
|
const old = this._selection;
|
||||||
|
this._selection = selection;
|
||||||
if (old) {
|
if (old) {
|
||||||
this.blur(old);
|
this.blur(old);
|
||||||
}
|
}
|
||||||
this._selection = selection;
|
if (selection && isSameDetailSelection(this._selection, selection)) {
|
||||||
if (selection) {
|
|
||||||
this.focus(selection);
|
this.focus(selection);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -49,7 +71,7 @@ export class DetailSelection {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(private readonly viewEle: RecordDetail) {}
|
constructor(private readonly viewEle: DetailSelectionHost) {}
|
||||||
|
|
||||||
blur(selection: DetailViewSelection) {
|
blur(selection: DetailViewSelection) {
|
||||||
const container = this.getFocusCellContainer(selection);
|
const container = this.getFocusCellContainer(selection);
|
||||||
@@ -111,8 +133,10 @@ export class DetailSelection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
focusFirstCell() {
|
focusFirstCell() {
|
||||||
const firstId = this.viewEle.querySelector('affine-data-view-record-field')
|
const firstField = this.viewEle.querySelector(
|
||||||
?.column.id;
|
'affine-data-view-record-field'
|
||||||
|
) as RecordField | undefined;
|
||||||
|
const firstId = firstField?.column.id;
|
||||||
if (firstId) {
|
if (firstId) {
|
||||||
this.selection = {
|
this.selection = {
|
||||||
propertyId: firstId,
|
propertyId: firstId,
|
||||||
@@ -144,11 +168,12 @@ export class DetailSelection {
|
|||||||
|
|
||||||
getSelectCard(selection: KanbanCardSelection) {
|
getSelectCard(selection: KanbanCardSelection) {
|
||||||
const { groupKey, cardId } = selection.cards[0];
|
const { groupKey, cardId } = selection.cards[0];
|
||||||
|
const group = this.viewEle.querySelector(
|
||||||
|
`affine-data-view-kanban-group[data-key="${groupKey}"]`
|
||||||
|
) as HTMLElement | undefined;
|
||||||
|
|
||||||
return this.viewEle
|
return group?.querySelector(
|
||||||
.querySelector(`affine-data-view-kanban-group[data-key="${groupKey}"]`)
|
`affine-data-view-kanban-card[data-card-id="${cardId}"]`
|
||||||
?.querySelector(
|
) as KanbanCard | undefined;
|
||||||
`affine-data-view-kanban-card[data-card-id="${cardId}"]`
|
|
||||||
) as KanbanCard | undefined;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -247,12 +247,13 @@ export const groupByMatchers: GroupByConfig[] = [
|
|||||||
matchType: t.boolean.instance(),
|
matchType: t.boolean.instance(),
|
||||||
groupName: (_t, v) => `${v?.toString() ?? ''}`,
|
groupName: (_t, v) => `${v?.toString() ?? ''}`,
|
||||||
defaultKeys: _t => [
|
defaultKeys: _t => [
|
||||||
ungroups,
|
|
||||||
{ key: 'true', value: true },
|
{ key: 'true', value: true },
|
||||||
{ key: 'false', value: false },
|
{ key: 'false', value: false },
|
||||||
],
|
],
|
||||||
valuesGroup: (v, _t) =>
|
valuesGroup: (v, _t) =>
|
||||||
typeof v !== 'boolean' ? [ungroups] : [{ key: v.toString(), value: v }],
|
typeof v !== 'boolean'
|
||||||
|
? [{ key: 'false', value: false }]
|
||||||
|
: [{ key: v.toString(), value: v }],
|
||||||
addToGroup: (v: boolean | null, _old: boolean | null) => v,
|
addToGroup: (v: boolean | null, _old: boolean | null) => v,
|
||||||
view: createUniComponentFromWebComponent(BooleanGroupView),
|
view: createUniComponentFromWebComponent(BooleanGroupView),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { css, html, unsafeCSS } from 'lit';
|
|||||||
import { property, query } from 'lit/decorators.js';
|
import { property, query } from 'lit/decorators.js';
|
||||||
import { repeat } from 'lit/directives/repeat.js';
|
import { repeat } from 'lit/directives/repeat.js';
|
||||||
|
|
||||||
|
import { canGroupable } from '../../view-presets/kanban/group-by-utils.js';
|
||||||
import { KanbanSingleView } from '../../view-presets/kanban/kanban-view-manager.js';
|
import { KanbanSingleView } from '../../view-presets/kanban/kanban-view-manager.js';
|
||||||
import { TableSingleView } from '../../view-presets/table/table-view-manager.js';
|
import { TableSingleView } from '../../view-presets/table/table-view-manager.js';
|
||||||
import { dataViewCssVariable } from '../common/css-variable.js';
|
import { dataViewCssVariable } from '../common/css-variable.js';
|
||||||
@@ -278,6 +279,9 @@ export const selectGroupByProperty = (
|
|||||||
if (property.type$.value === 'title') {
|
if (property.type$.value === 'title') {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (view instanceof KanbanSingleView) {
|
||||||
|
return canGroupable(view.manager.dataSource, property.id);
|
||||||
|
}
|
||||||
const dataType = property.dataType$.value;
|
const dataType = property.dataType$.value;
|
||||||
if (!dataType) {
|
if (!dataType) {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ export type GetJsonValueFromConfig<T> =
|
|||||||
export type PropertyConfig<Data, RawValue = unknown, JsonValue = unknown> = {
|
export type PropertyConfig<Data, RawValue = unknown, JsonValue = unknown> = {
|
||||||
name: string;
|
name: string;
|
||||||
hide?: boolean;
|
hide?: boolean;
|
||||||
|
kanbanGroup?: {
|
||||||
|
enabled: boolean;
|
||||||
|
mutable?: boolean;
|
||||||
|
};
|
||||||
propertyData: {
|
propertyData: {
|
||||||
schema: ZodType<Data>;
|
schema: ZodType<Data>;
|
||||||
default: () => Data;
|
default: () => Data;
|
||||||
|
|||||||
@@ -12,6 +12,5 @@ export type PropertyDataUpdater<
|
|||||||
> = (data: Data) => Partial<Data>;
|
> = (data: Data) => Partial<Data>;
|
||||||
|
|
||||||
export interface DatabaseFlags {
|
export interface DatabaseFlags {
|
||||||
enable_number_formatting: boolean;
|
|
||||||
enable_table_virtual_scroll: boolean;
|
enable_table_virtual_scroll: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ const FALSE_VALUES = new Set([
|
|||||||
|
|
||||||
export const checkboxPropertyModelConfig = checkboxPropertyType.modelConfig({
|
export const checkboxPropertyModelConfig = checkboxPropertyType.modelConfig({
|
||||||
name: 'Checkbox',
|
name: 'Checkbox',
|
||||||
|
kanbanGroup: {
|
||||||
|
enabled: true,
|
||||||
|
mutable: true,
|
||||||
|
},
|
||||||
propertyData: {
|
propertyData: {
|
||||||
schema: zod.object({}),
|
schema: zod.object({}),
|
||||||
default: () => ({}),
|
default: () => ({}),
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ export const multiSelectPropertyType = propertyType('multi-select');
|
|||||||
export const multiSelectPropertyModelConfig =
|
export const multiSelectPropertyModelConfig =
|
||||||
multiSelectPropertyType.modelConfig({
|
multiSelectPropertyType.modelConfig({
|
||||||
name: 'Multi-select',
|
name: 'Multi-select',
|
||||||
|
kanbanGroup: {
|
||||||
|
enabled: true,
|
||||||
|
mutable: true,
|
||||||
|
},
|
||||||
propertyData: {
|
propertyData: {
|
||||||
schema: SelectPropertySchema,
|
schema: SelectPropertySchema,
|
||||||
default: () => ({
|
default: () => ({
|
||||||
|
|||||||
@@ -24,17 +24,11 @@ export class NumberCell extends BaseCellRenderer<
|
|||||||
private accessor _inputEle!: HTMLInputElement;
|
private accessor _inputEle!: HTMLInputElement;
|
||||||
|
|
||||||
private _getFormattedString(value: number | undefined = this.value) {
|
private _getFormattedString(value: number | undefined = this.value) {
|
||||||
const enableNewFormatting =
|
|
||||||
this.view.featureFlags$.value.enable_number_formatting;
|
|
||||||
const decimals = this.property.data$.value.decimal ?? 0;
|
const decimals = this.property.data$.value.decimal ?? 0;
|
||||||
const formatMode = (this.property.data$.value.format ??
|
const formatMode = (this.property.data$.value.format ??
|
||||||
'number') as NumberFormat;
|
'number') as NumberFormat;
|
||||||
|
|
||||||
return value != undefined
|
return value != undefined ? formatNumber(value, formatMode, decimals) : '';
|
||||||
? enableNewFormatting
|
|
||||||
? formatNumber(value, formatMode, decimals)
|
|
||||||
: value.toString()
|
|
||||||
: '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private readonly _keydown = (e: KeyboardEvent) => {
|
private readonly _keydown = (e: KeyboardEvent) => {
|
||||||
@@ -58,9 +52,7 @@ export class NumberCell extends BaseCellRenderer<
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const enableNewFormatting =
|
const value = parseNumber(str);
|
||||||
this.view.featureFlags$.value.enable_number_formatting;
|
|
||||||
const value = enableNewFormatting ? parseNumber(str) : parseFloat(str);
|
|
||||||
if (isNaN(value)) {
|
if (isNaN(value)) {
|
||||||
if (this._inputEle) {
|
if (this._inputEle) {
|
||||||
this._inputEle.value = this.value
|
this._inputEle.value = this.value
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import zod from 'zod';
|
|||||||
import { t } from '../../core/logical/type-presets.js';
|
import { t } from '../../core/logical/type-presets.js';
|
||||||
import { propertyType } from '../../core/property/property-config.js';
|
import { propertyType } from '../../core/property/property-config.js';
|
||||||
import { NumberPropertySchema } from './types.js';
|
import { NumberPropertySchema } from './types.js';
|
||||||
|
import { parseNumber } from './utils/formatter.js';
|
||||||
export const numberPropertyType = propertyType('number');
|
export const numberPropertyType = propertyType('number');
|
||||||
|
|
||||||
export const numberPropertyModelConfig = numberPropertyType.modelConfig({
|
export const numberPropertyModelConfig = numberPropertyType.modelConfig({
|
||||||
@@ -21,7 +22,7 @@ export const numberPropertyModelConfig = numberPropertyType.modelConfig({
|
|||||||
default: () => null,
|
default: () => null,
|
||||||
toString: ({ value }) => value?.toString() ?? '',
|
toString: ({ value }) => value?.toString() ?? '',
|
||||||
fromString: ({ value }) => {
|
fromString: ({ value }) => {
|
||||||
const num = value ? Number(value) : NaN;
|
const num = value ? parseNumber(value) : NaN;
|
||||||
return { value: isNaN(num) ? null : num };
|
return { value: isNaN(num) ? null : num };
|
||||||
},
|
},
|
||||||
toJson: ({ value }) => value ?? null,
|
toJson: ({ value }) => value ?? null,
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ export const SelectPropertySchema = zod.object({
|
|||||||
export type SelectPropertyData = zod.infer<typeof SelectPropertySchema>;
|
export type SelectPropertyData = zod.infer<typeof SelectPropertySchema>;
|
||||||
export const selectPropertyModelConfig = selectPropertyType.modelConfig({
|
export const selectPropertyModelConfig = selectPropertyType.modelConfig({
|
||||||
name: 'Select',
|
name: 'Select',
|
||||||
|
kanbanGroup: {
|
||||||
|
enabled: true,
|
||||||
|
mutable: true,
|
||||||
|
},
|
||||||
propertyData: {
|
propertyData: {
|
||||||
schema: SelectPropertySchema,
|
schema: SelectPropertySchema,
|
||||||
default: () => ({
|
default: () => ({
|
||||||
|
|||||||
@@ -3,17 +3,9 @@ import { kanbanViewModel } from './kanban/index.js';
|
|||||||
import { tableViewModel } from './table/index.js';
|
import { tableViewModel } from './table/index.js';
|
||||||
|
|
||||||
export const viewConverts = [
|
export const viewConverts = [
|
||||||
createViewConvert(tableViewModel, kanbanViewModel, data => {
|
createViewConvert(tableViewModel, kanbanViewModel, data => ({
|
||||||
if (data.groupBy) {
|
filter: data.filter,
|
||||||
return {
|
})),
|
||||||
filter: data.filter,
|
|
||||||
groupBy: data.groupBy,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
filter: data.filter,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
createViewConvert(kanbanViewModel, tableViewModel, data => ({
|
createViewConvert(kanbanViewModel, tableViewModel, data => ({
|
||||||
filter: data.filter,
|
filter: data.filter,
|
||||||
groupBy: data.groupBy,
|
groupBy: data.groupBy,
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
|||||||
|
|
||||||
import type { GroupBy, GroupProperty } from '../../core/common/types.js';
|
import type { GroupBy, GroupProperty } from '../../core/common/types.js';
|
||||||
import type { FilterGroup } from '../../core/filter/types.js';
|
import type { FilterGroup } from '../../core/filter/types.js';
|
||||||
import { defaultGroupBy, getGroupByService, t } from '../../core/index.js';
|
|
||||||
import type { Sort } from '../../core/sort/types.js';
|
import type { Sort } from '../../core/sort/types.js';
|
||||||
import { type BasicViewDataType, viewType } from '../../core/view/data-view.js';
|
import { type BasicViewDataType, viewType } from '../../core/view/data-view.js';
|
||||||
|
import { resolveKanbanGroupBy } from './group-by-utils.js';
|
||||||
import { KanbanSingleView } from './kanban-view-manager.js';
|
import { KanbanSingleView } from './kanban-view-manager.js';
|
||||||
|
|
||||||
export const kanbanViewType = viewType('kanban');
|
export const kanbanViewType = viewType('kanban');
|
||||||
@@ -34,41 +34,16 @@ export const kanbanViewModel = kanbanViewType.createModel<KanbanViewData>({
|
|||||||
defaultName: 'Kanban View',
|
defaultName: 'Kanban View',
|
||||||
dataViewManager: KanbanSingleView,
|
dataViewManager: KanbanSingleView,
|
||||||
defaultData: viewManager => {
|
defaultData: viewManager => {
|
||||||
const groupByService = getGroupByService(viewManager.dataSource);
|
const groupBy = resolveKanbanGroupBy(viewManager.dataSource);
|
||||||
const columns = viewManager.dataSource.properties$.value;
|
if (!groupBy) {
|
||||||
const allowList = columns.filter(columnId => {
|
|
||||||
const dataType = viewManager.dataSource.propertyDataTypeGet(columnId);
|
|
||||||
return dataType && !!groupByService?.matcher.match(dataType);
|
|
||||||
});
|
|
||||||
const getWeight = (columnId: string) => {
|
|
||||||
const dataType = viewManager.dataSource.propertyDataTypeGet(columnId);
|
|
||||||
if (!dataType || t.string.is(dataType) || t.richText.is(dataType)) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
if (t.tag.is(dataType)) {
|
|
||||||
return 3;
|
|
||||||
}
|
|
||||||
if (t.array.is(dataType)) {
|
|
||||||
return 2;
|
|
||||||
}
|
|
||||||
return 1;
|
|
||||||
};
|
|
||||||
const columnId = allowList.sort((a, b) => getWeight(b) - getWeight(a))[0];
|
|
||||||
if (!columnId) {
|
|
||||||
throw new BlockSuiteError(
|
throw new BlockSuiteError(
|
||||||
ErrorCode.DatabaseBlockError,
|
ErrorCode.DatabaseBlockError,
|
||||||
'no groupable column found'
|
'no groupable column found'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const type = viewManager.dataSource.propertyTypeGet(columnId);
|
|
||||||
const meta = type && viewManager.dataSource.propertyMetaGet(type);
|
const columns = viewManager.dataSource.properties$.value;
|
||||||
const data = viewManager.dataSource.propertyDataGet(columnId);
|
|
||||||
if (!columnId || !meta || !data) {
|
|
||||||
throw new BlockSuiteError(
|
|
||||||
ErrorCode.DatabaseBlockError,
|
|
||||||
'not implement yet'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
columns: columns.map(id => ({
|
columns: columns.map(id => ({
|
||||||
id: id,
|
id: id,
|
||||||
@@ -78,7 +53,7 @@ export const kanbanViewModel = kanbanViewType.createModel<KanbanViewData>({
|
|||||||
op: 'and',
|
op: 'and',
|
||||||
conditions: [],
|
conditions: [],
|
||||||
},
|
},
|
||||||
groupBy: defaultGroupBy(viewManager.dataSource, meta, columnId, data),
|
groupBy,
|
||||||
header: {
|
header: {
|
||||||
titleColumn: viewManager.dataSource.properties$.value.find(
|
titleColumn: viewManager.dataSource.properties$.value.find(
|
||||||
id => viewManager.dataSource.propertyTypeGet(id) === 'title'
|
id => viewManager.dataSource.propertyTypeGet(id) === 'title'
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import { nanoid } from '@blocksuite/store';
|
||||||
|
|
||||||
|
import type { GroupBy } from '../../core/common/types.js';
|
||||||
|
import { getTagColor } from '../../core/component/tags/colors.js';
|
||||||
|
import type { DataSource } from '../../core/data-source/base.js';
|
||||||
|
import { defaultGroupBy } from '../../core/group-by/default.js';
|
||||||
|
import { getGroupByService } from '../../core/group-by/matcher.js';
|
||||||
|
|
||||||
|
type KanbanGroupCapability = 'mutable' | 'immutable' | 'none';
|
||||||
|
|
||||||
|
const KANBAN_DEFAULT_STATUS_OPTIONS = ['Todo', 'In Progress', 'Done'];
|
||||||
|
const SHOW_EMPTY_GROUPS_BY_DEFAULT = new Set(['select', 'multi-select']);
|
||||||
|
|
||||||
|
export const getKanbanDefaultHideEmpty = (groupName?: string): boolean => {
|
||||||
|
return !groupName || !SHOW_EMPTY_GROUPS_BY_DEFAULT.has(groupName);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getKanbanGroupCapability = (
|
||||||
|
dataSource: DataSource,
|
||||||
|
propertyId: string
|
||||||
|
): KanbanGroupCapability => {
|
||||||
|
const type = dataSource.propertyTypeGet(propertyId);
|
||||||
|
if (!type) {
|
||||||
|
return 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = dataSource.propertyMetaGet(type);
|
||||||
|
const kanbanGroup = meta?.config.kanbanGroup;
|
||||||
|
if (!kanbanGroup?.enabled) {
|
||||||
|
return 'none';
|
||||||
|
}
|
||||||
|
return kanbanGroup.mutable ? 'mutable' : 'immutable';
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasMatchingGroupBy = (dataSource: DataSource, propertyId: string) => {
|
||||||
|
const dataType = dataSource.propertyDataTypeGet(propertyId);
|
||||||
|
if (!dataType) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const groupByService = getGroupByService(dataSource);
|
||||||
|
return !!groupByService?.matcher.match(dataType);
|
||||||
|
};
|
||||||
|
|
||||||
|
const createGroupByFromColumn = (
|
||||||
|
dataSource: DataSource,
|
||||||
|
columnId: string
|
||||||
|
): GroupBy | undefined => {
|
||||||
|
const type = dataSource.propertyTypeGet(columnId);
|
||||||
|
if (!type) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const meta = dataSource.propertyMetaGet(type);
|
||||||
|
if (!meta) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return defaultGroupBy(
|
||||||
|
dataSource,
|
||||||
|
meta,
|
||||||
|
columnId,
|
||||||
|
dataSource.propertyDataGet(columnId)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const canGroupable = (dataSource: DataSource, propertyId: string) => {
|
||||||
|
return (
|
||||||
|
getKanbanGroupCapability(dataSource, propertyId) !== 'none' &&
|
||||||
|
hasMatchingGroupBy(dataSource, propertyId)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const pickKanbanGroupColumn = (
|
||||||
|
dataSource: DataSource,
|
||||||
|
propertyIds: string[] = dataSource.properties$.value
|
||||||
|
): string | undefined => {
|
||||||
|
let immutableFallback: string | undefined;
|
||||||
|
|
||||||
|
for (const propertyId of propertyIds) {
|
||||||
|
const capability = getKanbanGroupCapability(dataSource, propertyId);
|
||||||
|
if (capability === 'none' || !hasMatchingGroupBy(dataSource, propertyId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (capability === 'mutable') {
|
||||||
|
return propertyId;
|
||||||
|
}
|
||||||
|
immutableFallback ??= propertyId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return immutableFallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ensureKanbanGroupColumn = (
|
||||||
|
dataSource: DataSource
|
||||||
|
): string | undefined => {
|
||||||
|
const columnId = pickKanbanGroupColumn(dataSource);
|
||||||
|
if (columnId) {
|
||||||
|
return columnId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusId = dataSource.propertyAdd('end', {
|
||||||
|
type: 'select',
|
||||||
|
name: 'Status',
|
||||||
|
});
|
||||||
|
if (!statusId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dataSource.propertyDataSet(statusId, {
|
||||||
|
options: KANBAN_DEFAULT_STATUS_OPTIONS.map(value => ({
|
||||||
|
id: nanoid(),
|
||||||
|
value,
|
||||||
|
color: getTagColor(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
return statusId;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveKanbanGroupBy = (
|
||||||
|
dataSource: DataSource,
|
||||||
|
current?: GroupBy
|
||||||
|
): GroupBy | undefined => {
|
||||||
|
const keepColumnId =
|
||||||
|
current?.columnId && canGroupable(dataSource, current.columnId)
|
||||||
|
? current.columnId
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const columnId = keepColumnId ?? ensureKanbanGroupColumn(dataSource);
|
||||||
|
if (!columnId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = createGroupByFromColumn(dataSource, columnId);
|
||||||
|
if (!next) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...next,
|
||||||
|
sort: current?.sort,
|
||||||
|
hideEmpty: current?.hideEmpty ?? getKanbanDefaultHideEmpty(next.name),
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -17,7 +17,52 @@ import {
|
|||||||
import { fromJson } from '../../core/property/utils';
|
import { fromJson } from '../../core/property/utils';
|
||||||
import { PropertyBase } from '../../core/view-manager/property.js';
|
import { PropertyBase } from '../../core/view-manager/property.js';
|
||||||
import { SingleViewBase } from '../../core/view-manager/single-view.js';
|
import { SingleViewBase } from '../../core/view-manager/single-view.js';
|
||||||
import type { KanbanViewData } from './define.js';
|
import type { ViewManager } from '../../core/view-manager/view-manager.js';
|
||||||
|
import type { KanbanViewColumn, KanbanViewData } from './define.js';
|
||||||
|
import {
|
||||||
|
getKanbanDefaultHideEmpty,
|
||||||
|
resolveKanbanGroupBy,
|
||||||
|
} from './group-by-utils.js';
|
||||||
|
|
||||||
|
const materializeColumnsByPropertyIds = (
|
||||||
|
columns: KanbanViewColumn[],
|
||||||
|
propertyIds: string[]
|
||||||
|
) => {
|
||||||
|
const needShow = new Set(propertyIds);
|
||||||
|
const orderedColumns: KanbanViewColumn[] = [];
|
||||||
|
|
||||||
|
for (const column of columns) {
|
||||||
|
if (needShow.has(column.id)) {
|
||||||
|
orderedColumns.push(column);
|
||||||
|
needShow.delete(column.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const id of needShow) {
|
||||||
|
orderedColumns.push({ id });
|
||||||
|
}
|
||||||
|
|
||||||
|
return orderedColumns;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const materializeKanbanColumns = (
|
||||||
|
columns: KanbanViewColumn[],
|
||||||
|
propertyIds: string[]
|
||||||
|
) => {
|
||||||
|
const nextColumns = materializeColumnsByPropertyIds(columns, propertyIds);
|
||||||
|
const unchanged =
|
||||||
|
columns.length === nextColumns.length &&
|
||||||
|
columns.every((column, index) => {
|
||||||
|
const nextColumn = nextColumns[index];
|
||||||
|
return (
|
||||||
|
nextColumn != null &&
|
||||||
|
column.id === nextColumn.id &&
|
||||||
|
column.hide === nextColumn.hide
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return unchanged ? columns : nextColumns;
|
||||||
|
};
|
||||||
|
|
||||||
export class KanbanSingleView extends SingleViewBase<KanbanViewData> {
|
export class KanbanSingleView extends SingleViewBase<KanbanViewData> {
|
||||||
propertiesRaw$ = computed(() => {
|
propertiesRaw$ = computed(() => {
|
||||||
@@ -61,16 +106,27 @@ export class KanbanSingleView extends SingleViewBase<KanbanViewData> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
groupBy$ = computed(() => {
|
groupBy$ = computed(() => {
|
||||||
return this.data$.value?.groupBy;
|
const groupBy = this.data$.value?.groupBy;
|
||||||
|
if (!groupBy || groupBy.hideEmpty != null) {
|
||||||
|
return groupBy;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...groupBy,
|
||||||
|
hideEmpty: getKanbanDefaultHideEmpty(groupBy.name),
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
groupTrait = this.traitSet(
|
groupTrait = this.traitSet(
|
||||||
groupTraitKey,
|
groupTraitKey,
|
||||||
new GroupTrait(this.groupBy$, this, {
|
new GroupTrait(this.groupBy$, this, {
|
||||||
groupBySet: groupBy => {
|
groupBySet: groupBy => {
|
||||||
|
const nextGroupBy = resolveKanbanGroupBy(
|
||||||
|
this.manager.dataSource,
|
||||||
|
groupBy
|
||||||
|
);
|
||||||
this.dataUpdate(() => {
|
this.dataUpdate(() => {
|
||||||
return {
|
return {
|
||||||
groupBy: groupBy,
|
groupBy: nextGroupBy,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -200,6 +256,23 @@ export class KanbanSingleView extends SingleViewBase<KanbanViewData> {
|
|||||||
return this.view?.mode ?? 'kanban';
|
return this.view?.mode ?? 'kanban';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private materializeColumns() {
|
||||||
|
const view = this.view;
|
||||||
|
if (!view) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextColumns = materializeKanbanColumns(
|
||||||
|
view.columns,
|
||||||
|
this.dataSource.properties$.value
|
||||||
|
);
|
||||||
|
if (nextColumns === view.columns) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dataUpdate(() => ({ columns: nextColumns }));
|
||||||
|
}
|
||||||
|
|
||||||
get view() {
|
get view() {
|
||||||
return this.data$.value;
|
return this.data$.value;
|
||||||
}
|
}
|
||||||
@@ -289,6 +362,13 @@ export class KanbanSingleView extends SingleViewBase<KanbanViewData> {
|
|||||||
propertyGetOrCreate(columnId: string): KanbanColumn {
|
propertyGetOrCreate(columnId: string): KanbanColumn {
|
||||||
return new KanbanColumn(this, columnId);
|
return new KanbanColumn(this, columnId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
constructor(viewManager: ViewManager, viewId: string) {
|
||||||
|
super(viewManager, viewId);
|
||||||
|
// Materialize view columns on view activation so newly added properties
|
||||||
|
// can participate in hide/order operations in kanban.
|
||||||
|
this.materializeColumns();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type KanbanColumnData = KanbanViewData['columns'][number];
|
type KanbanColumnData = KanbanViewData['columns'][number];
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ const createDragPreview = (card: KanbanCard, x: number, y: number) => {
|
|||||||
div.className = 'with-data-view-css-variable';
|
div.className = 'with-data-view-css-variable';
|
||||||
div.style.width = `${card.getBoundingClientRect().width}px`;
|
div.style.width = `${card.getBoundingClientRect().width}px`;
|
||||||
div.style.position = 'fixed';
|
div.style.position = 'fixed';
|
||||||
// div.style.pointerEvents = 'none';
|
div.style.pointerEvents = 'none';
|
||||||
div.style.transform = 'rotate(-3deg)';
|
div.style.transform = 'rotate(-3deg)';
|
||||||
div.style.left = `${x}px`;
|
div.style.left = `${x}px`;
|
||||||
div.style.top = `${y}px`;
|
div.style.top = `${y}px`;
|
||||||
@@ -209,8 +209,12 @@ const createDragPreview = (card: KanbanCard, x: number, y: number) => {
|
|||||||
};
|
};
|
||||||
const createDropPreview = () => {
|
const createDropPreview = () => {
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.style.height = '2px';
|
div.dataset.isDropPreview = 'true';
|
||||||
div.style.borderRadius = '1px';
|
div.style.pointerEvents = 'none';
|
||||||
|
div.style.position = 'fixed';
|
||||||
|
div.style.zIndex = '9999';
|
||||||
|
div.style.height = '3px';
|
||||||
|
div.style.borderRadius = '2px';
|
||||||
div.style.backgroundColor = 'var(--affine-primary-color)';
|
div.style.backgroundColor = 'var(--affine-primary-color)';
|
||||||
div.style.boxShadow = '0px 0px 8px 0px rgba(30, 150, 235, 0.35)';
|
div.style.boxShadow = '0px 0px 8px 0px rgba(30, 150, 235, 0.35)';
|
||||||
return {
|
return {
|
||||||
@@ -219,19 +223,50 @@ const createDropPreview = () => {
|
|||||||
self: KanbanCard | undefined,
|
self: KanbanCard | undefined,
|
||||||
card?: KanbanCard
|
card?: KanbanCard
|
||||||
) {
|
) {
|
||||||
const target = card ?? group.querySelector('.add-card');
|
if (card === self) {
|
||||||
if (!target) {
|
|
||||||
console.error('`target` is not found');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (target.previousElementSibling === self || target === self) {
|
|
||||||
div.remove();
|
div.remove();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (target.previousElementSibling === div) {
|
|
||||||
|
if (!card) {
|
||||||
|
const cards = Array.from(
|
||||||
|
group.querySelectorAll('affine-data-view-kanban-card')
|
||||||
|
);
|
||||||
|
const lastCard = cards[cards.length - 1];
|
||||||
|
if (lastCard === self) {
|
||||||
|
div.remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let rect: DOMRect | undefined;
|
||||||
|
let y = 0;
|
||||||
|
if (card) {
|
||||||
|
rect = card.getBoundingClientRect();
|
||||||
|
y = rect.top;
|
||||||
|
} else {
|
||||||
|
const addCard = group.querySelector('.add-card');
|
||||||
|
if (addCard instanceof HTMLElement) {
|
||||||
|
rect = addCard.getBoundingClientRect();
|
||||||
|
y = rect.top;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!rect) {
|
||||||
|
const body = group.querySelector('.group-body');
|
||||||
|
if (body instanceof HTMLElement) {
|
||||||
|
rect = body.getBoundingClientRect();
|
||||||
|
y = rect.bottom;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!rect) {
|
||||||
|
div.remove();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
target.insertAdjacentElement('beforebegin', div);
|
|
||||||
|
document.body.append(div);
|
||||||
|
div.style.left = `${Math.round(rect.left)}px`;
|
||||||
|
div.style.top = `${Math.round(y - 2)}px`;
|
||||||
|
div.style.width = `${Math.round(rect.width)}px`;
|
||||||
},
|
},
|
||||||
remove() {
|
remove() {
|
||||||
div.remove();
|
div.remove();
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { html } from 'lit/static-html.js';
|
|||||||
|
|
||||||
import { groupTraitKey } from '../../../core/group-by/trait.js';
|
import { groupTraitKey } from '../../../core/group-by/trait.js';
|
||||||
import type { SingleView } from '../../../core/index.js';
|
import type { SingleView } from '../../../core/index.js';
|
||||||
|
import { canGroupable } from '../group-by-utils.js';
|
||||||
|
|
||||||
const styles = css`
|
const styles = css`
|
||||||
affine-data-view-kanban-header {
|
affine-data-view-kanban-header {
|
||||||
@@ -43,7 +44,12 @@ export class KanbanHeader extends SignalWatcher(
|
|||||||
popMenu(popupTargetFromElement(e.target as HTMLElement), {
|
popMenu(popupTargetFromElement(e.target as HTMLElement), {
|
||||||
options: {
|
options: {
|
||||||
items: this.view.properties$.value
|
items: this.view.properties$.value
|
||||||
.filter(column => column.id !== groupTrait.property$.value?.id)
|
.filter(column => {
|
||||||
|
if (column.id === groupTrait.property$.value?.id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return canGroupable(this.view.manager.dataSource, column.id);
|
||||||
|
})
|
||||||
.map(column => {
|
.map(column => {
|
||||||
return menu.action({
|
return menu.action({
|
||||||
name: column.name$.value,
|
name: column.name$.value,
|
||||||
|
|||||||
@@ -64,9 +64,6 @@ export class MobileTableColumnHeader extends SignalWatcher(
|
|||||||
};
|
};
|
||||||
|
|
||||||
private popMenu(ele?: HTMLElement) {
|
private popMenu(ele?: HTMLElement) {
|
||||||
const enableNumberFormatting =
|
|
||||||
this.tableViewManager.featureFlags$.value.enable_number_formatting;
|
|
||||||
|
|
||||||
popMenu(popupTargetFromElement(ele ?? this), {
|
popMenu(popupTargetFromElement(ele ?? this), {
|
||||||
options: {
|
options: {
|
||||||
title: {
|
title: {
|
||||||
@@ -76,41 +73,36 @@ export class MobileTableColumnHeader extends SignalWatcher(
|
|||||||
inputConfig(this.column),
|
inputConfig(this.column),
|
||||||
typeConfig(this.column),
|
typeConfig(this.column),
|
||||||
// Number format begin
|
// Number format begin
|
||||||
...(enableNumberFormatting
|
menu.subMenu({
|
||||||
? [
|
name: 'Number Format',
|
||||||
menu.subMenu({
|
hide: () =>
|
||||||
name: 'Number Format',
|
!this.column.dataUpdate || this.column.type$.value !== 'number',
|
||||||
hide: () =>
|
options: {
|
||||||
!this.column.dataUpdate ||
|
title: {
|
||||||
this.column.type$.value !== 'number',
|
text: 'Number Format',
|
||||||
options: {
|
},
|
||||||
title: {
|
items: [
|
||||||
text: 'Number Format',
|
numberFormatConfig(this.column),
|
||||||
|
...numberFormats.map(format => {
|
||||||
|
const data = this.column.data$.value;
|
||||||
|
return menu.action({
|
||||||
|
isSelected: data.format === format.type,
|
||||||
|
prefix: html`<span
|
||||||
|
style="font-size: var(--affine-font-base); scale: 1.2;"
|
||||||
|
>${format.symbol}</span
|
||||||
|
>`,
|
||||||
|
name: format.label,
|
||||||
|
select: () => {
|
||||||
|
if (data.format === format.type) return;
|
||||||
|
this.column.dataUpdate(() => ({
|
||||||
|
format: format.type,
|
||||||
|
}));
|
||||||
},
|
},
|
||||||
items: [
|
});
|
||||||
numberFormatConfig(this.column),
|
|
||||||
...numberFormats.map(format => {
|
|
||||||
const data = this.column.data$.value;
|
|
||||||
return menu.action({
|
|
||||||
isSelected: data.format === format.type,
|
|
||||||
prefix: html`<span
|
|
||||||
style="font-size: var(--affine-font-base); scale: 1.2;"
|
|
||||||
>${format.symbol}</span
|
|
||||||
>`,
|
|
||||||
name: format.label,
|
|
||||||
select: () => {
|
|
||||||
if (data.format === format.type) return;
|
|
||||||
this.column.dataUpdate(() => ({
|
|
||||||
format: format.type,
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
]
|
],
|
||||||
: []),
|
},
|
||||||
|
}),
|
||||||
// Number format end
|
// Number format end
|
||||||
menu.group({
|
menu.group({
|
||||||
items: [
|
items: [
|
||||||
|
|||||||
@@ -205,47 +205,39 @@ export class DatabaseHeaderColumn extends SignalWatcher(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private popMenu(ele?: HTMLElement) {
|
private popMenu(ele?: HTMLElement) {
|
||||||
const enableNumberFormatting =
|
|
||||||
this.tableViewManager.featureFlags$.value.enable_number_formatting;
|
|
||||||
|
|
||||||
popMenu(popupTargetFromElement(ele ?? this), {
|
popMenu(popupTargetFromElement(ele ?? this), {
|
||||||
options: {
|
options: {
|
||||||
items: [
|
items: [
|
||||||
inputConfig(this.column),
|
inputConfig(this.column),
|
||||||
typeConfig(this.column),
|
typeConfig(this.column),
|
||||||
// Number format begin
|
// Number format begin
|
||||||
...(enableNumberFormatting
|
menu.subMenu({
|
||||||
? [
|
name: 'Number Format',
|
||||||
menu.subMenu({
|
hide: () =>
|
||||||
name: 'Number Format',
|
!this.column.dataUpdate || this.column.type$.value !== 'number',
|
||||||
hide: () =>
|
options: {
|
||||||
!this.column.dataUpdate ||
|
items: [
|
||||||
this.column.type$.value !== 'number',
|
numberFormatConfig(this.column),
|
||||||
options: {
|
...numberFormats.map(format => {
|
||||||
items: [
|
const data = this.column.data$.value;
|
||||||
numberFormatConfig(this.column),
|
return menu.action({
|
||||||
...numberFormats.map(format => {
|
isSelected: data.format === format.type,
|
||||||
const data = this.column.data$.value;
|
prefix: html`<span
|
||||||
return menu.action({
|
style="font-size: var(--affine-font-base); scale: 1.2;"
|
||||||
isSelected: data.format === format.type,
|
>${format.symbol}</span
|
||||||
prefix: html`<span
|
>`,
|
||||||
style="font-size: var(--affine-font-base); scale: 1.2;"
|
name: format.label,
|
||||||
>${format.symbol}</span
|
select: () => {
|
||||||
>`,
|
if (data.format === format.type) return;
|
||||||
name: format.label,
|
this.column.dataUpdate(() => ({
|
||||||
select: () => {
|
format: format.type,
|
||||||
if (data.format === format.type) return;
|
}));
|
||||||
this.column.dataUpdate(() => ({
|
},
|
||||||
format: format.type,
|
});
|
||||||
}));
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
]
|
],
|
||||||
: []),
|
},
|
||||||
|
}),
|
||||||
// Number format end
|
// Number format end
|
||||||
menu.group({
|
menu.group({
|
||||||
items: [
|
items: [
|
||||||
|
|||||||
@@ -205,47 +205,39 @@ export class DatabaseHeaderColumn extends SignalWatcher(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private popMenu(ele?: HTMLElement) {
|
private popMenu(ele?: HTMLElement) {
|
||||||
const enableNumberFormatting =
|
|
||||||
this.tableViewManager.featureFlags$.value.enable_number_formatting;
|
|
||||||
|
|
||||||
popMenu(popupTargetFromElement(ele ?? this), {
|
popMenu(popupTargetFromElement(ele ?? this), {
|
||||||
options: {
|
options: {
|
||||||
items: [
|
items: [
|
||||||
inputConfig(this.column),
|
inputConfig(this.column),
|
||||||
typeConfig(this.column),
|
typeConfig(this.column),
|
||||||
// Number format begin
|
// Number format begin
|
||||||
...(enableNumberFormatting
|
menu.subMenu({
|
||||||
? [
|
name: 'Number Format',
|
||||||
menu.subMenu({
|
hide: () =>
|
||||||
name: 'Number Format',
|
!this.column.dataUpdate || this.column.type$.value !== 'number',
|
||||||
hide: () =>
|
options: {
|
||||||
!this.column.dataUpdate ||
|
items: [
|
||||||
this.column.type$.value !== 'number',
|
numberFormatConfig(this.column),
|
||||||
options: {
|
...numberFormats.map(format => {
|
||||||
items: [
|
const data = this.column.data$.value;
|
||||||
numberFormatConfig(this.column),
|
return menu.action({
|
||||||
...numberFormats.map(format => {
|
isSelected: data.format === format.type,
|
||||||
const data = this.column.data$.value;
|
prefix: html`<span
|
||||||
return menu.action({
|
style="font-size: var(--affine-font-base); scale: 1.2;"
|
||||||
isSelected: data.format === format.type,
|
>${format.symbol}</span
|
||||||
prefix: html`<span
|
>`,
|
||||||
style="font-size: var(--affine-font-base); scale: 1.2;"
|
name: format.label,
|
||||||
>${format.symbol}</span
|
select: () => {
|
||||||
>`,
|
if (data.format === format.type) return;
|
||||||
name: format.label,
|
this.column.dataUpdate(() => ({
|
||||||
select: () => {
|
format: format.type,
|
||||||
if (data.format === format.type) return;
|
}));
|
||||||
this.column.dataUpdate(() => ({
|
},
|
||||||
format: format.type,
|
});
|
||||||
}));
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
]
|
],
|
||||||
: []),
|
},
|
||||||
|
}),
|
||||||
// Number format end
|
// Number format end
|
||||||
menu.group({
|
menu.group({
|
||||||
items: [
|
items: [
|
||||||
|
|||||||
@@ -337,6 +337,7 @@ export const popViewOptions = (
|
|||||||
const reopen = () => {
|
const reopen = () => {
|
||||||
popViewOptions(target, dataViewLogic);
|
popViewOptions(target, dataViewLogic);
|
||||||
};
|
};
|
||||||
|
let handler: ReturnType<typeof popMenu>;
|
||||||
const items: MenuConfig[] = [];
|
const items: MenuConfig[] = [];
|
||||||
items.push(
|
items.push(
|
||||||
menu.input({
|
menu.input({
|
||||||
@@ -350,16 +351,9 @@ export const popViewOptions = (
|
|||||||
items.push(
|
items.push(
|
||||||
menu.group({
|
menu.group({
|
||||||
items: [
|
items: [
|
||||||
menu.action({
|
menu => {
|
||||||
name: 'Layout',
|
const viewTypeItems = menu.renderItems(
|
||||||
postfix: html` <div
|
view.manager.viewMetas.map<MenuConfig>(meta => {
|
||||||
style="font-size: 14px;text-transform: capitalize;"
|
|
||||||
>
|
|
||||||
${view.type}
|
|
||||||
</div>
|
|
||||||
${ArrowRightSmallIcon()}`,
|
|
||||||
select: () => {
|
|
||||||
const viewTypes = view.manager.viewMetas.map<MenuConfig>(meta => {
|
|
||||||
return menu => {
|
return menu => {
|
||||||
if (!menu.search(meta.model.defaultName)) {
|
if (!menu.search(meta.model.defaultName)) {
|
||||||
return;
|
return;
|
||||||
@@ -379,10 +373,10 @@ export const popViewOptions = (
|
|||||||
? 'var(--affine-text-emphasis-color)'
|
? 'var(--affine-text-emphasis-color)'
|
||||||
: 'var(--affine-text-secondary-color)',
|
: 'var(--affine-text-secondary-color)',
|
||||||
});
|
});
|
||||||
const data: MenuButtonData = {
|
const buttonData: MenuButtonData = {
|
||||||
content: () => html`
|
content: () => html`
|
||||||
<div
|
<div
|
||||||
style="color:var(--affine-text-emphasis-color);width:100%;display: flex;flex-direction: column;align-items: center;justify-content: center;padding: 6px 16px;white-space: nowrap"
|
style="width:100%;display: flex;flex-direction: column;align-items: center;justify-content: center;padding: 6px 16px;white-space: nowrap"
|
||||||
>
|
>
|
||||||
<div style="${iconStyle}">
|
<div style="${iconStyle}">
|
||||||
${renderUniLit(meta.renderer.icon)}
|
${renderUniLit(meta.renderer.icon)}
|
||||||
@@ -392,7 +386,7 @@ export const popViewOptions = (
|
|||||||
`,
|
`,
|
||||||
select: () => {
|
select: () => {
|
||||||
const id = view.manager.currentViewId$.value;
|
const id = view.manager.currentViewId$.value;
|
||||||
if (!id) {
|
if (!id || meta.type === view.type) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
view.manager.viewChangeType(id, meta.type);
|
view.manager.viewChangeType(id, meta.type);
|
||||||
@@ -403,55 +397,35 @@ export const popViewOptions = (
|
|||||||
const containerStyle = styleMap({
|
const containerStyle = styleMap({
|
||||||
flex: '1',
|
flex: '1',
|
||||||
});
|
});
|
||||||
return html` <affine-menu-button
|
return html`<affine-menu-button
|
||||||
style="${containerStyle}"
|
style="${containerStyle}"
|
||||||
.data="${data}"
|
.data="${buttonData}"
|
||||||
.menu="${menu}"
|
.menu="${menu}"
|
||||||
></affine-menu-button>`;
|
></affine-menu-button>`;
|
||||||
};
|
};
|
||||||
});
|
})
|
||||||
const subHandler = popMenu(target, {
|
);
|
||||||
options: {
|
if (!viewTypeItems.length) {
|
||||||
title: {
|
return html``;
|
||||||
onBack: reopen,
|
}
|
||||||
text: 'Layout',
|
return html`
|
||||||
},
|
<div style="display:flex;align-items:center;gap:8px;padding:0 2px;">
|
||||||
items: [
|
<div
|
||||||
menu => {
|
style="display:flex;align-items:center;color:var(--affine-icon-color);"
|
||||||
const result = menu.renderItems(viewTypes);
|
>
|
||||||
if (result.length) {
|
${LayoutIcon()}
|
||||||
return html` <div style="display: flex">${result}</div>`;
|
</div>
|
||||||
}
|
<div
|
||||||
return html``;
|
style="font-size:14px;line-height:22px;color:var(--affine-text-secondary-color);"
|
||||||
},
|
>
|
||||||
// menu.toggleSwitch({
|
Layout
|
||||||
// name: 'Show block icon',
|
</div>
|
||||||
// on: true,
|
</div>
|
||||||
// onChange: value => {
|
<div style="display:flex;gap:8px;margin-top:8px;">
|
||||||
// console.log(value);
|
${viewTypeItems}
|
||||||
// },
|
</div>
|
||||||
// }),
|
`;
|
||||||
// menu.toggleSwitch({
|
},
|
||||||
// name: 'Show Vertical lines',
|
|
||||||
// on: true,
|
|
||||||
// onChange: value => {
|
|
||||||
// console.log(value);
|
|
||||||
// },
|
|
||||||
// }),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
middleware: [
|
|
||||||
autoPlacement({
|
|
||||||
allowedPlacements: ['bottom-start', 'top-start'],
|
|
||||||
}),
|
|
||||||
offset({ mainAxis: 15, crossAxis: -162 }),
|
|
||||||
shift({ crossAxis: true }),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
subHandler.menu.menuElement.style.minHeight = '550px';
|
|
||||||
},
|
|
||||||
prefix: LayoutIcon(),
|
|
||||||
}),
|
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -486,7 +460,6 @@ export const popViewOptions = (
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
let handler: ReturnType<typeof popMenu>;
|
|
||||||
handler = popMenu(target, {
|
handler = popMenu(target, {
|
||||||
options: {
|
options: {
|
||||||
title: {
|
title: {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, test } from 'vitest';
|
import { describe, expect, test } from 'vitest';
|
||||||
|
|
||||||
import { isValidUrl } from '../../utils/url.js';
|
import { isValidUrl, splitTextByUrl } from '../../utils/url.js';
|
||||||
|
|
||||||
describe('isValidUrl: determining whether a URL is valid is very complicated', () => {
|
describe('isValidUrl: determining whether a URL is valid is very complicated', () => {
|
||||||
test('basic case', () => {
|
test('basic case', () => {
|
||||||
@@ -85,3 +85,55 @@ describe('isValidUrl: determining whether a URL is valid is very complicated', (
|
|||||||
expect(isValidUrl('http://127.0.0.1', 'http://127.0.0.1')).toEqual(true);
|
expect(isValidUrl('http://127.0.0.1', 'http://127.0.0.1')).toEqual(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('splitTextByUrl', () => {
|
||||||
|
test('should split text and keep url part as link segment', () => {
|
||||||
|
expect(splitTextByUrl('hi - https://google.com')).toEqual([
|
||||||
|
{ text: 'hi - ' },
|
||||||
|
{
|
||||||
|
text: 'https://google.com',
|
||||||
|
link: 'https://google.com',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should support prefixed url token without swallowing prefix text', () => {
|
||||||
|
expect(splitTextByUrl('-https://google.com')).toEqual([
|
||||||
|
{ text: '-' },
|
||||||
|
{
|
||||||
|
text: 'https://google.com',
|
||||||
|
link: 'https://google.com',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should trim tail punctuations from url token', () => {
|
||||||
|
expect(splitTextByUrl('visit https://google.com, now')).toEqual([
|
||||||
|
{ text: 'visit ' },
|
||||||
|
{
|
||||||
|
text: 'https://google.com',
|
||||||
|
link: 'https://google.com',
|
||||||
|
},
|
||||||
|
{ text: ', now' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should convert domain token in plain text', () => {
|
||||||
|
expect(splitTextByUrl('google.com and text')).toEqual([
|
||||||
|
{
|
||||||
|
text: 'google.com',
|
||||||
|
link: 'https://google.com',
|
||||||
|
},
|
||||||
|
{ text: ' and text' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should normalize www domain token link while preserving display text', () => {
|
||||||
|
expect(splitTextByUrl('www.google.com')).toEqual([
|
||||||
|
{
|
||||||
|
text: 'www.google.com',
|
||||||
|
link: 'https://www.google.com',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { type Store, StoreExtension } from '@blocksuite/store';
|
|||||||
import { type Signal, signal } from '@preact/signals-core';
|
import { type Signal, signal } from '@preact/signals-core';
|
||||||
|
|
||||||
export interface BlockSuiteFlags {
|
export interface BlockSuiteFlags {
|
||||||
enable_database_number_formatting: boolean;
|
|
||||||
enable_database_attachment_note: boolean;
|
enable_database_attachment_note: boolean;
|
||||||
enable_database_full_width: boolean;
|
enable_database_full_width: boolean;
|
||||||
enable_block_query: boolean;
|
enable_block_query: boolean;
|
||||||
@@ -28,7 +27,6 @@ export class FeatureFlagService extends StoreExtension {
|
|||||||
static override key = 'feature-flag-server';
|
static override key = 'feature-flag-server';
|
||||||
|
|
||||||
private readonly _flags: Signal<BlockSuiteFlags> = signal({
|
private readonly _flags: Signal<BlockSuiteFlags> = signal({
|
||||||
enable_database_number_formatting: false,
|
|
||||||
enable_database_attachment_note: false,
|
enable_database_attachment_note: false,
|
||||||
enable_database_full_width: false,
|
enable_database_full_width: false,
|
||||||
enable_block_query: false,
|
enable_block_query: false,
|
||||||
|
|||||||
@@ -95,28 +95,107 @@ export function isValidUrl(str: string, baseUrl = location.origin) {
|
|||||||
return result?.allowed ?? false;
|
return result?.allowed ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const URL_SCHEME_IN_TOKEN_REGEXP =
|
||||||
|
/(?:https?:\/\/|ftp:\/\/|sftp:\/\/|mailto:|tel:|www\.)/i;
|
||||||
|
|
||||||
|
const URL_LEADING_DELIMITER_REGEXP = /^[-([{<'"~]+/;
|
||||||
|
|
||||||
|
const URL_TRAILING_DELIMITER_REGEXP = /[)\]}>.,;:!?'"]+$/;
|
||||||
|
|
||||||
|
export type UrlTextSegment = {
|
||||||
|
text: string;
|
||||||
|
link?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function appendUrlTextSegment(
|
||||||
|
segments: UrlTextSegment[],
|
||||||
|
segment: UrlTextSegment
|
||||||
|
) {
|
||||||
|
if (!segment.text) return;
|
||||||
|
const last = segments[segments.length - 1];
|
||||||
|
if (last && !last.link && !segment.link) {
|
||||||
|
last.text += segment.text;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
segments.push(segment);
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitTokenByUrl(token: string, baseUrl: string): UrlTextSegment[] {
|
||||||
|
const schemeMatch = token.match(URL_SCHEME_IN_TOKEN_REGEXP);
|
||||||
|
const schemeIndex = schemeMatch?.index;
|
||||||
|
if (typeof schemeIndex === 'number' && schemeIndex > 0) {
|
||||||
|
return [
|
||||||
|
{ text: token.slice(0, schemeIndex) },
|
||||||
|
...splitTokenByUrl(token.slice(schemeIndex), baseUrl),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const leading = token.match(URL_LEADING_DELIMITER_REGEXP)?.[0] ?? '';
|
||||||
|
const withoutLeading = token.slice(leading.length);
|
||||||
|
const trailing =
|
||||||
|
withoutLeading.match(URL_TRAILING_DELIMITER_REGEXP)?.[0] ?? '';
|
||||||
|
const core = trailing
|
||||||
|
? withoutLeading.slice(0, withoutLeading.length - trailing.length)
|
||||||
|
: withoutLeading;
|
||||||
|
|
||||||
|
if (core && isValidUrl(core, baseUrl)) {
|
||||||
|
const segments: UrlTextSegment[] = [];
|
||||||
|
appendUrlTextSegment(segments, { text: leading });
|
||||||
|
appendUrlTextSegment(segments, { text: core, link: normalizeUrl(core) });
|
||||||
|
appendUrlTextSegment(segments, { text: trailing });
|
||||||
|
return segments;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [{ text: token }];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split plain text into mixed segments, where only URL segments carry link metadata.
|
||||||
|
* This is used by paste handlers so text like `example:https://google.com` keeps
|
||||||
|
* normal text while only URL parts are linkified.
|
||||||
|
*/
|
||||||
|
export function splitTextByUrl(text: string, baseUrl = location.origin) {
|
||||||
|
const chunks = text.match(/\s+|\S+/g);
|
||||||
|
if (!chunks) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const segments: UrlTextSegment[] = [];
|
||||||
|
chunks.forEach(chunk => {
|
||||||
|
if (/^\s+$/.test(chunk)) {
|
||||||
|
appendUrlTextSegment(segments, { text: chunk });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
splitTokenByUrl(chunk, baseUrl).forEach(segment => {
|
||||||
|
appendUrlTextSegment(segments, segment);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return segments;
|
||||||
|
}
|
||||||
|
|
||||||
// https://en.wikipedia.org/wiki/Top-level_domain
|
// https://en.wikipedia.org/wiki/Top-level_domain
|
||||||
const COMMON_TLDS = new Set([
|
const COMMON_TLDS = new Set([
|
||||||
'com',
|
|
||||||
'org',
|
|
||||||
'net',
|
|
||||||
'edu',
|
|
||||||
'gov',
|
|
||||||
'co',
|
|
||||||
'io',
|
|
||||||
'me',
|
|
||||||
'moe',
|
|
||||||
'mil',
|
|
||||||
'top',
|
|
||||||
'dev',
|
|
||||||
'xyz',
|
|
||||||
'info',
|
|
||||||
'cat',
|
'cat',
|
||||||
'ru',
|
'co',
|
||||||
|
'com',
|
||||||
'de',
|
'de',
|
||||||
|
'dev',
|
||||||
|
'edu',
|
||||||
|
'eu',
|
||||||
|
'gov',
|
||||||
|
'info',
|
||||||
|
'io',
|
||||||
'jp',
|
'jp',
|
||||||
'uk',
|
'me',
|
||||||
|
'mil',
|
||||||
|
'moe',
|
||||||
|
'net',
|
||||||
|
'org',
|
||||||
'pro',
|
'pro',
|
||||||
|
'ru',
|
||||||
|
'top',
|
||||||
|
'uk',
|
||||||
|
'xyz',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function isCommonTLD(url: URL) {
|
function isCommonTLD(url: URL) {
|
||||||
|
|||||||
@@ -37,12 +37,7 @@ function extractTokenFromHeader(authorization: string) {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthService implements OnApplicationBootstrap {
|
export class AuthService implements OnApplicationBootstrap {
|
||||||
readonly cookieOptions: CookieOptions = {
|
readonly cookieOptions: CookieOptions;
|
||||||
sameSite: 'lax',
|
|
||||||
httpOnly: true,
|
|
||||||
path: '/',
|
|
||||||
secure: this.config.server.https,
|
|
||||||
};
|
|
||||||
static readonly sessionCookieName = 'affine_session';
|
static readonly sessionCookieName = 'affine_session';
|
||||||
static readonly userCookieName = 'affine_user_id';
|
static readonly userCookieName = 'affine_user_id';
|
||||||
static readonly csrfCookieName = 'affine_csrf_token';
|
static readonly csrfCookieName = 'affine_csrf_token';
|
||||||
@@ -51,7 +46,14 @@ export class AuthService implements OnApplicationBootstrap {
|
|||||||
private readonly config: Config,
|
private readonly config: Config,
|
||||||
private readonly models: Models,
|
private readonly models: Models,
|
||||||
private readonly mailer: Mailer
|
private readonly mailer: Mailer
|
||||||
) {}
|
) {
|
||||||
|
this.cookieOptions = {
|
||||||
|
sameSite: 'lax',
|
||||||
|
httpOnly: true,
|
||||||
|
path: '/',
|
||||||
|
secure: this.config.server.https,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async onApplicationBootstrap() {
|
async onApplicationBootstrap() {
|
||||||
if (env.dev) {
|
if (env.dev) {
|
||||||
|
|||||||
@@ -54,7 +54,7 @@
|
|||||||
"@toeverything/infra": "workspace:*",
|
"@toeverything/infra": "workspace:*",
|
||||||
"@types/set-cookie-parser": "^2.4.10",
|
"@types/set-cookie-parser": "^2.4.10",
|
||||||
"@types/uuid": "^11.0.0",
|
"@types/uuid": "^11.0.0",
|
||||||
"@vitejs/plugin-react-swc": "^3.7.2",
|
"@vitejs/plugin-react-swc": "^4.0.0",
|
||||||
"app-builder-lib": "^26.1.0",
|
"app-builder-lib": "^26.1.0",
|
||||||
"builder-util-runtime": "^9.5.0",
|
"builder-util-runtime": "^9.5.0",
|
||||||
"cross-env": "^10.1.0",
|
"cross-env": "^10.1.0",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { WorkspaceSQLiteDB } from '../nbstore/v1/workspace-db-adapter';
|
|||||||
import type { WorkspaceMeta } from '../type';
|
import type { WorkspaceMeta } from '../type';
|
||||||
import {
|
import {
|
||||||
getDeletedWorkspacesBasePath,
|
getDeletedWorkspacesBasePath,
|
||||||
|
getSpaceBasePath,
|
||||||
getSpaceDBPath,
|
getSpaceDBPath,
|
||||||
getWorkspaceBasePathV1,
|
getWorkspaceBasePathV1,
|
||||||
getWorkspaceMeta,
|
getWorkspaceMeta,
|
||||||
@@ -96,6 +97,33 @@ export async function storeWorkspaceMeta(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listLocalWorkspaceIds(): Promise<string[]> {
|
||||||
|
const localWorkspaceBasePath = path.join(
|
||||||
|
await getSpaceBasePath('workspace'),
|
||||||
|
'local'
|
||||||
|
);
|
||||||
|
if (!(await fs.pathExists(localWorkspaceBasePath))) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = await fs.readdir(localWorkspaceBasePath);
|
||||||
|
const ids = await Promise.all(
|
||||||
|
entries.map(async entry => {
|
||||||
|
const workspacePath = path.join(localWorkspaceBasePath, entry);
|
||||||
|
const stat = await fs.stat(workspacePath).catch(() => null);
|
||||||
|
if (!stat?.isDirectory()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!(await fs.pathExists(path.join(workspacePath, 'storage.db')))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return entry;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return ids.filter((id): id is string => typeof id === 'string');
|
||||||
|
}
|
||||||
|
|
||||||
type WorkspaceDocMeta = {
|
type WorkspaceDocMeta = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
deleteBackupWorkspace,
|
deleteBackupWorkspace,
|
||||||
deleteWorkspace,
|
deleteWorkspace,
|
||||||
getDeletedWorkspaces,
|
getDeletedWorkspaces,
|
||||||
|
listLocalWorkspaceIds,
|
||||||
trashWorkspace,
|
trashWorkspace,
|
||||||
} from './handlers';
|
} from './handlers';
|
||||||
|
|
||||||
@@ -18,4 +19,5 @@ export const workspaceHandlers = {
|
|||||||
return getDeletedWorkspaces();
|
return getDeletedWorkspaces();
|
||||||
},
|
},
|
||||||
deleteBackupWorkspace: async (id: string) => deleteBackupWorkspace(id),
|
deleteBackupWorkspace: async (id: string) => deleteBackupWorkspace(id),
|
||||||
|
listLocalWorkspaceIds: async () => listLocalWorkspaceIds(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -33,6 +33,43 @@ afterAll(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('workspace db management', () => {
|
describe('workspace db management', () => {
|
||||||
|
test('list local workspace ids', async () => {
|
||||||
|
const { listLocalWorkspaceIds } =
|
||||||
|
await import('@affine/electron/helper/workspace/handlers');
|
||||||
|
const validWorkspaceId = v4();
|
||||||
|
const noDbWorkspaceId = v4();
|
||||||
|
const fileEntry = 'README.txt';
|
||||||
|
|
||||||
|
const validWorkspacePath = path.join(
|
||||||
|
appDataPath,
|
||||||
|
'workspaces',
|
||||||
|
'local',
|
||||||
|
validWorkspaceId
|
||||||
|
);
|
||||||
|
const noDbWorkspacePath = path.join(
|
||||||
|
appDataPath,
|
||||||
|
'workspaces',
|
||||||
|
'local',
|
||||||
|
noDbWorkspaceId
|
||||||
|
);
|
||||||
|
const nonDirectoryPath = path.join(
|
||||||
|
appDataPath,
|
||||||
|
'workspaces',
|
||||||
|
'local',
|
||||||
|
fileEntry
|
||||||
|
);
|
||||||
|
|
||||||
|
await fs.ensureDir(validWorkspacePath);
|
||||||
|
await fs.ensureFile(path.join(validWorkspacePath, 'storage.db'));
|
||||||
|
await fs.ensureDir(noDbWorkspacePath);
|
||||||
|
await fs.outputFile(nonDirectoryPath, 'not-a-workspace');
|
||||||
|
|
||||||
|
const ids = await listLocalWorkspaceIds();
|
||||||
|
expect(ids).toContain(validWorkspaceId);
|
||||||
|
expect(ids).not.toContain(noDbWorkspaceId);
|
||||||
|
expect(ids).not.toContain(fileEntry);
|
||||||
|
});
|
||||||
|
|
||||||
test('trash workspace', async () => {
|
test('trash workspace', async () => {
|
||||||
const { trashWorkspace } =
|
const { trashWorkspace } =
|
||||||
await import('@affine/electron/helper/workspace/handlers');
|
await import('@affine/electron/helper/workspace/handlers');
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ let package = Package(
|
|||||||
],
|
],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.package(path: "../AffineResources"),
|
.package(path: "../AffineResources"),
|
||||||
.package(url: "https://github.com/RevenueCat/purchases-ios-spm.git", from: "5.56.1"),
|
.package(url: "https://github.com/RevenueCat/purchases-ios-spm.git", from: "5.58.0"),
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
.target(
|
.target(
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ import zod from 'zod';
|
|||||||
export const createdByColumnType = propertyType('created-by');
|
export const createdByColumnType = propertyType('created-by');
|
||||||
export const createdByPropertyModelConfig = createdByColumnType.modelConfig({
|
export const createdByPropertyModelConfig = createdByColumnType.modelConfig({
|
||||||
name: 'Created By',
|
name: 'Created By',
|
||||||
|
kanbanGroup: {
|
||||||
|
enabled: true,
|
||||||
|
mutable: false,
|
||||||
|
},
|
||||||
propertyData: {
|
propertyData: {
|
||||||
schema: zod.object({}),
|
schema: zod.object({}),
|
||||||
default: () => ({}),
|
default: () => ({}),
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ export type MemberCellJsonValueType = zod.TypeOf<
|
|||||||
>;
|
>;
|
||||||
export const memberPropertyModelConfig = memberColumnType.modelConfig({
|
export const memberPropertyModelConfig = memberColumnType.modelConfig({
|
||||||
name: 'Member',
|
name: 'Member',
|
||||||
|
kanbanGroup: {
|
||||||
|
enabled: true,
|
||||||
|
mutable: true,
|
||||||
|
},
|
||||||
propertyData: {
|
propertyData: {
|
||||||
schema: zod.object({}),
|
schema: zod.object({}),
|
||||||
default: () => ({}),
|
default: () => ({}),
|
||||||
|
|||||||
@@ -48,15 +48,44 @@ import { WorkspaceImpl } from '../../workspace/impls/workspace';
|
|||||||
import { getWorkspaceProfileWorker } from './out-worker';
|
import { getWorkspaceProfileWorker } from './out-worker';
|
||||||
|
|
||||||
export const LOCAL_WORKSPACE_LOCAL_STORAGE_KEY = 'affine-local-workspace';
|
export const LOCAL_WORKSPACE_LOCAL_STORAGE_KEY = 'affine-local-workspace';
|
||||||
|
export const LOCAL_WORKSPACE_GLOBAL_STATE_KEY =
|
||||||
|
'workspace-engine:local-workspace-ids:v1';
|
||||||
const LOCAL_WORKSPACE_CHANGED_BROADCAST_CHANNEL_KEY =
|
const LOCAL_WORKSPACE_CHANGED_BROADCAST_CHANNEL_KEY =
|
||||||
'affine-local-workspace-changed';
|
'affine-local-workspace-changed';
|
||||||
|
|
||||||
const logger = new DebugLogger('local-workspace');
|
const logger = new DebugLogger('local-workspace');
|
||||||
|
|
||||||
export function getLocalWorkspaceIds(): string[] {
|
type GlobalStateStorageLike = {
|
||||||
|
ready: Promise<void>;
|
||||||
|
get<T>(key: string): T | undefined;
|
||||||
|
set<T>(key: string, value: T): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeWorkspaceIds(ids: unknown): string[] {
|
||||||
|
if (!Array.isArray(ids)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return ids.filter((id): id is string => typeof id === 'string');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getElectronGlobalStateStorage(): GlobalStateStorageLike | null {
|
||||||
|
if (!BUILD_CONFIG.isElectron) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const sharedStorage = (
|
||||||
|
globalThis as {
|
||||||
|
__sharedStorage?: { globalState?: GlobalStateStorageLike };
|
||||||
|
}
|
||||||
|
).__sharedStorage;
|
||||||
|
return sharedStorage?.globalState ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLegacyLocalWorkspaceIds(): string[] {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(
|
return normalizeWorkspaceIds(
|
||||||
localStorage.getItem(LOCAL_WORKSPACE_LOCAL_STORAGE_KEY) ?? '[]'
|
JSON.parse(
|
||||||
|
localStorage.getItem(LOCAL_WORKSPACE_LOCAL_STORAGE_KEY) ?? '[]'
|
||||||
|
)
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('Failed to get local workspace ids', e);
|
logger.error('Failed to get local workspace ids', e);
|
||||||
@@ -64,21 +93,98 @@ export function getLocalWorkspaceIds(): string[] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getLocalWorkspaceIds(): string[] {
|
||||||
|
const globalState = getElectronGlobalStateStorage();
|
||||||
|
if (globalState) {
|
||||||
|
const value = globalState.get(LOCAL_WORKSPACE_GLOBAL_STATE_KEY);
|
||||||
|
if (value !== undefined) {
|
||||||
|
return normalizeWorkspaceIds(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return getLegacyLocalWorkspaceIds();
|
||||||
|
}
|
||||||
|
|
||||||
export function setLocalWorkspaceIds(
|
export function setLocalWorkspaceIds(
|
||||||
idsOrUpdater: string[] | ((ids: string[]) => string[])
|
idsOrUpdater: string[] | ((ids: string[]) => string[])
|
||||||
) {
|
) {
|
||||||
localStorage.setItem(
|
const next = normalizeWorkspaceIds(
|
||||||
LOCAL_WORKSPACE_LOCAL_STORAGE_KEY,
|
typeof idsOrUpdater === 'function'
|
||||||
JSON.stringify(
|
? idsOrUpdater(getLocalWorkspaceIds())
|
||||||
typeof idsOrUpdater === 'function'
|
: idsOrUpdater
|
||||||
? idsOrUpdater(getLocalWorkspaceIds())
|
|
||||||
: idsOrUpdater
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
const deduplicated = [...new Set(next)];
|
||||||
|
|
||||||
|
const globalState = getElectronGlobalStateStorage();
|
||||||
|
if (globalState) {
|
||||||
|
globalState.set(LOCAL_WORKSPACE_GLOBAL_STATE_KEY, deduplicated);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
localStorage.setItem(
|
||||||
|
LOCAL_WORKSPACE_LOCAL_STORAGE_KEY,
|
||||||
|
JSON.stringify(deduplicated)
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Failed to set local workspace ids', e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class LocalWorkspaceFlavourProvider implements WorkspaceFlavourProvider {
|
class LocalWorkspaceFlavourProvider implements WorkspaceFlavourProvider {
|
||||||
constructor(private readonly framework: FrameworkProvider) {}
|
constructor(private readonly framework: FrameworkProvider) {
|
||||||
|
if (BUILD_CONFIG.isElectron) {
|
||||||
|
void this.ensureWorkspaceIdsMigrated();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private migration: Promise<void> | null = null;
|
||||||
|
|
||||||
|
private ensureWorkspaceIdsMigrated() {
|
||||||
|
if (!BUILD_CONFIG.isElectron) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.migration) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.migration = (async () => {
|
||||||
|
const electronApi = this.framework.get(DesktopApiService);
|
||||||
|
await electronApi.sharedStorage.globalState.ready;
|
||||||
|
|
||||||
|
const persistedIds = normalizeWorkspaceIds(
|
||||||
|
electronApi.sharedStorage.globalState.get(
|
||||||
|
LOCAL_WORKSPACE_GLOBAL_STATE_KEY
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const legacyIds = getLegacyLocalWorkspaceIds();
|
||||||
|
|
||||||
|
let scannedIds: string[] = [];
|
||||||
|
try {
|
||||||
|
scannedIds =
|
||||||
|
await electronApi.handler.workspace.listLocalWorkspaceIds();
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Failed to scan local workspace ids', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
setLocalWorkspaceIds(currentIds => {
|
||||||
|
return [
|
||||||
|
...new Set([
|
||||||
|
...currentIds,
|
||||||
|
...persistedIds,
|
||||||
|
...legacyIds,
|
||||||
|
...scannedIds,
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
})()
|
||||||
|
.catch(e => {
|
||||||
|
logger.error('Failed to migrate local workspace ids', e);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.notifyChannel.postMessage(null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
readonly flavour = 'local';
|
readonly flavour = 'local';
|
||||||
readonly notifyChannel = new BroadcastChannel(
|
readonly notifyChannel = new BroadcastChannel(
|
||||||
@@ -242,6 +348,9 @@ class LocalWorkspaceFlavourProvider implements WorkspaceFlavourProvider {
|
|||||||
);
|
);
|
||||||
isRevalidating$ = new LiveData(false);
|
isRevalidating$ = new LiveData(false);
|
||||||
revalidate(): void {
|
revalidate(): void {
|
||||||
|
if (BUILD_CONFIG.isElectron) {
|
||||||
|
void this.ensureWorkspaceIdsMigrated();
|
||||||
|
}
|
||||||
// notify livedata to re-scan workspaces
|
// notify livedata to re-scan workspaces
|
||||||
this.notifyChannel.postMessage(null);
|
this.notifyChannel.postMessage(null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import { GlobalState } from '../storage';
|
|||||||
import { WorkspaceFlavoursProvider } from '../workspace';
|
import { WorkspaceFlavoursProvider } from '../workspace';
|
||||||
import { CloudWorkspaceFlavoursProvider } from './impls/cloud';
|
import { CloudWorkspaceFlavoursProvider } from './impls/cloud';
|
||||||
import {
|
import {
|
||||||
LOCAL_WORKSPACE_LOCAL_STORAGE_KEY,
|
|
||||||
LocalWorkspaceFlavoursProvider,
|
LocalWorkspaceFlavoursProvider,
|
||||||
|
setLocalWorkspaceIds,
|
||||||
} from './impls/local';
|
} from './impls/local';
|
||||||
|
|
||||||
export { base64ToUint8Array, uint8ArrayToBase64 } from './utils/base64';
|
export { base64ToUint8Array, uint8ArrayToBase64 } from './utils/base64';
|
||||||
@@ -25,12 +25,5 @@ export function configureBrowserWorkspaceFlavours(framework: Framework) {
|
|||||||
* Used after copying sqlite database file to appdata folder
|
* Used after copying sqlite database file to appdata folder
|
||||||
*/
|
*/
|
||||||
export function _addLocalWorkspace(id: string) {
|
export function _addLocalWorkspace(id: string) {
|
||||||
const allWorkspaceIDs: string[] = JSON.parse(
|
setLocalWorkspaceIds(ids => (ids.includes(id) ? ids : [...ids, id]));
|
||||||
localStorage.getItem(LOCAL_WORKSPACE_LOCAL_STORAGE_KEY) ?? '[]'
|
|
||||||
);
|
|
||||||
allWorkspaceIDs.push(id);
|
|
||||||
localStorage.setItem(
|
|
||||||
LOCAL_WORKSPACE_LOCAL_STORAGE_KEY,
|
|
||||||
JSON.stringify(allWorkspaceIDs)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
'base_path': '.'
|
|
||||||
'base_url': 'https://api.crowdin.com'
|
|
||||||
|
|
||||||
'preserve_hierarchy': true
|
|
||||||
|
|
||||||
'files':
|
|
||||||
[
|
|
||||||
{
|
|
||||||
'source': '/src/resources/en.json',
|
|
||||||
'translation': '/src/resources/%locale%.json',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
@@ -338,8 +338,8 @@ test.describe('kanban view selection', () => {
|
|||||||
rows: ['row1'],
|
rows: ['row1'],
|
||||||
columns: [
|
columns: [
|
||||||
{
|
{
|
||||||
type: 'number',
|
type: 'checkbox',
|
||||||
value: [1],
|
value: [true],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'rich-text',
|
type: 'rich-text',
|
||||||
@@ -350,8 +350,6 @@ test.describe('kanban view selection', () => {
|
|||||||
|
|
||||||
await focusKanbanCardHeader(page);
|
await focusKanbanCardHeader(page);
|
||||||
await assertKanbanCellSelected(page, {
|
await assertKanbanCellSelected(page, {
|
||||||
// group by `number` column, `Ungroups` is hidden because it's empty (hideEmpty: true by default)
|
|
||||||
// so the first visible group is the one with value "1" at groupIndex: 0
|
|
||||||
groupIndex: 0,
|
groupIndex: 0,
|
||||||
cardIndex: 0,
|
cardIndex: 0,
|
||||||
cellIndex: 0,
|
cellIndex: 0,
|
||||||
@@ -380,9 +378,9 @@ test.describe('kanban view selection', () => {
|
|||||||
rows: ['row1', 'row2'],
|
rows: ['row1', 'row2'],
|
||||||
columns: [
|
columns: [
|
||||||
{
|
{
|
||||||
type: 'number',
|
type: 'checkbox',
|
||||||
// Both rows have value 1 to put them in the same group
|
// Both rows are checked so they stay in the same group.
|
||||||
value: [1, 1],
|
value: [true, true],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'rich-text',
|
type: 'rich-text',
|
||||||
@@ -394,8 +392,6 @@ test.describe('kanban view selection', () => {
|
|||||||
await focusKanbanCardHeader(page);
|
await focusKanbanCardHeader(page);
|
||||||
await pressArrowUp(page);
|
await pressArrowUp(page);
|
||||||
await assertKanbanCellSelected(page, {
|
await assertKanbanCellSelected(page, {
|
||||||
// `Ungroups` is hidden because it's empty (hideEmpty: true by default)
|
|
||||||
// so the first visible group is "1" at groupIndex: 0
|
|
||||||
groupIndex: 0,
|
groupIndex: 0,
|
||||||
cardIndex: 1,
|
cardIndex: 1,
|
||||||
cellIndex: 2,
|
cellIndex: 2,
|
||||||
@@ -414,18 +410,18 @@ test.describe('kanban view selection', () => {
|
|||||||
}) => {
|
}) => {
|
||||||
await enterPlaygroundRoom(page);
|
await enterPlaygroundRoom(page);
|
||||||
await initKanbanViewState(page, {
|
await initKanbanViewState(page, {
|
||||||
rows: ['row1', 'row2', 'row3'],
|
rows: ['row1', 'row2'],
|
||||||
columns: [
|
columns: [
|
||||||
{
|
{
|
||||||
type: 'number',
|
type: 'checkbox',
|
||||||
value: [undefined, 1, 10],
|
value: [true, false],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
await focusKanbanCardHeader(page);
|
await focusKanbanCardHeader(page);
|
||||||
|
|
||||||
await pressArrowRight(page, 3);
|
await pressArrowRight(page, 2);
|
||||||
await assertKanbanCellSelected(page, {
|
await assertKanbanCellSelected(page, {
|
||||||
groupIndex: 0,
|
groupIndex: 0,
|
||||||
cardIndex: 0,
|
cardIndex: 0,
|
||||||
@@ -434,7 +430,7 @@ test.describe('kanban view selection', () => {
|
|||||||
|
|
||||||
await pressArrowLeft(page);
|
await pressArrowLeft(page);
|
||||||
await assertKanbanCellSelected(page, {
|
await assertKanbanCellSelected(page, {
|
||||||
groupIndex: 2,
|
groupIndex: 1,
|
||||||
cardIndex: 0,
|
cardIndex: 0,
|
||||||
cellIndex: 0,
|
cellIndex: 0,
|
||||||
});
|
});
|
||||||
@@ -480,11 +476,11 @@ test.describe('kanban view selection', () => {
|
|||||||
}) => {
|
}) => {
|
||||||
await enterPlaygroundRoom(page);
|
await enterPlaygroundRoom(page);
|
||||||
await initKanbanViewState(page, {
|
await initKanbanViewState(page, {
|
||||||
rows: ['row1', 'row2', 'row3'],
|
rows: ['row1', 'row2'],
|
||||||
columns: [
|
columns: [
|
||||||
{
|
{
|
||||||
type: 'number',
|
type: 'checkbox',
|
||||||
value: [undefined, 1, 10],
|
value: [true, false],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -493,7 +489,7 @@ test.describe('kanban view selection', () => {
|
|||||||
await pressEscape(page);
|
await pressEscape(page);
|
||||||
await pressEscape(page);
|
await pressEscape(page);
|
||||||
|
|
||||||
await pressArrowRight(page, 3);
|
await pressArrowRight(page, 2);
|
||||||
await assertKanbanCardSelected(page, {
|
await assertKanbanCardSelected(page, {
|
||||||
groupIndex: 0,
|
groupIndex: 0,
|
||||||
cardIndex: 0,
|
cardIndex: 0,
|
||||||
@@ -501,7 +497,7 @@ test.describe('kanban view selection', () => {
|
|||||||
|
|
||||||
await pressArrowLeft(page);
|
await pressArrowLeft(page);
|
||||||
await assertKanbanCardSelected(page, {
|
await assertKanbanCardSelected(page, {
|
||||||
groupIndex: 2,
|
groupIndex: 1,
|
||||||
cardIndex: 0,
|
cardIndex: 0,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -512,8 +508,8 @@ test.describe('kanban view selection', () => {
|
|||||||
rows: ['row1', 'row2'],
|
rows: ['row1', 'row2'],
|
||||||
columns: [
|
columns: [
|
||||||
{
|
{
|
||||||
type: 'number',
|
type: 'checkbox',
|
||||||
value: [undefined, 1],
|
value: [true, false],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
120
yarn.lock
120
yarn.lock
@@ -593,7 +593,7 @@ __metadata:
|
|||||||
"@toeverything/infra": "workspace:*"
|
"@toeverything/infra": "workspace:*"
|
||||||
"@types/set-cookie-parser": "npm:^2.4.10"
|
"@types/set-cookie-parser": "npm:^2.4.10"
|
||||||
"@types/uuid": "npm:^11.0.0"
|
"@types/uuid": "npm:^11.0.0"
|
||||||
"@vitejs/plugin-react-swc": "npm:^3.7.2"
|
"@vitejs/plugin-react-swc": "npm:^4.0.0"
|
||||||
app-builder-lib: "npm:^26.1.0"
|
app-builder-lib: "npm:^26.1.0"
|
||||||
async-call-rpc: "npm:^6.4.2"
|
async-call-rpc: "npm:^6.4.2"
|
||||||
builder-util-runtime: "npm:^9.5.0"
|
builder-util-runtime: "npm:^9.5.0"
|
||||||
@@ -14886,6 +14886,13 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"@rolldown/pluginutils@npm:1.0.0-rc.2":
|
||||||
|
version: 1.0.0-rc.2
|
||||||
|
resolution: "@rolldown/pluginutils@npm:1.0.0-rc.2"
|
||||||
|
checksum: 10/8dba3626ca26f49ed83d4db4a9eaacfcc6715cc8544f2969419489c90a2bb000025976049e0f6c5c2880817bff753fb04bec8fb57df9423f07958ce8da97035e
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"@rollup/pluginutils@npm:^5.0.2, @rollup/pluginutils@npm:^5.1.3, @rollup/pluginutils@npm:^5.3.0":
|
"@rollup/pluginutils@npm:^5.0.2, @rollup/pluginutils@npm:^5.1.3, @rollup/pluginutils@npm:^5.3.0":
|
||||||
version: 5.3.0
|
version: 5.3.0
|
||||||
resolution: "@rollup/pluginutils@npm:5.3.0"
|
resolution: "@rollup/pluginutils@npm:5.3.0"
|
||||||
@@ -15817,92 +15824,92 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@swc/core-darwin-arm64@npm:1.11.29":
|
"@swc/core-darwin-arm64@npm:1.15.11":
|
||||||
version: 1.11.29
|
version: 1.15.11
|
||||||
resolution: "@swc/core-darwin-arm64@npm:1.11.29"
|
resolution: "@swc/core-darwin-arm64@npm:1.15.11"
|
||||||
conditions: os=darwin & cpu=arm64
|
conditions: os=darwin & cpu=arm64
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@swc/core-darwin-x64@npm:1.11.29":
|
"@swc/core-darwin-x64@npm:1.15.11":
|
||||||
version: 1.11.29
|
version: 1.15.11
|
||||||
resolution: "@swc/core-darwin-x64@npm:1.11.29"
|
resolution: "@swc/core-darwin-x64@npm:1.15.11"
|
||||||
conditions: os=darwin & cpu=x64
|
conditions: os=darwin & cpu=x64
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@swc/core-linux-arm-gnueabihf@npm:1.11.29":
|
"@swc/core-linux-arm-gnueabihf@npm:1.15.11":
|
||||||
version: 1.11.29
|
version: 1.15.11
|
||||||
resolution: "@swc/core-linux-arm-gnueabihf@npm:1.11.29"
|
resolution: "@swc/core-linux-arm-gnueabihf@npm:1.15.11"
|
||||||
conditions: os=linux & cpu=arm
|
conditions: os=linux & cpu=arm
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@swc/core-linux-arm64-gnu@npm:1.11.29":
|
"@swc/core-linux-arm64-gnu@npm:1.15.11":
|
||||||
version: 1.11.29
|
version: 1.15.11
|
||||||
resolution: "@swc/core-linux-arm64-gnu@npm:1.11.29"
|
resolution: "@swc/core-linux-arm64-gnu@npm:1.15.11"
|
||||||
conditions: os=linux & cpu=arm64 & libc=glibc
|
conditions: os=linux & cpu=arm64 & libc=glibc
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@swc/core-linux-arm64-musl@npm:1.11.29":
|
"@swc/core-linux-arm64-musl@npm:1.15.11":
|
||||||
version: 1.11.29
|
version: 1.15.11
|
||||||
resolution: "@swc/core-linux-arm64-musl@npm:1.11.29"
|
resolution: "@swc/core-linux-arm64-musl@npm:1.15.11"
|
||||||
conditions: os=linux & cpu=arm64 & libc=musl
|
conditions: os=linux & cpu=arm64 & libc=musl
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@swc/core-linux-x64-gnu@npm:1.11.29":
|
"@swc/core-linux-x64-gnu@npm:1.15.11":
|
||||||
version: 1.11.29
|
version: 1.15.11
|
||||||
resolution: "@swc/core-linux-x64-gnu@npm:1.11.29"
|
resolution: "@swc/core-linux-x64-gnu@npm:1.15.11"
|
||||||
conditions: os=linux & cpu=x64 & libc=glibc
|
conditions: os=linux & cpu=x64 & libc=glibc
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@swc/core-linux-x64-musl@npm:1.11.29":
|
"@swc/core-linux-x64-musl@npm:1.15.11":
|
||||||
version: 1.11.29
|
version: 1.15.11
|
||||||
resolution: "@swc/core-linux-x64-musl@npm:1.11.29"
|
resolution: "@swc/core-linux-x64-musl@npm:1.15.11"
|
||||||
conditions: os=linux & cpu=x64 & libc=musl
|
conditions: os=linux & cpu=x64 & libc=musl
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@swc/core-win32-arm64-msvc@npm:1.11.29":
|
"@swc/core-win32-arm64-msvc@npm:1.15.11":
|
||||||
version: 1.11.29
|
version: 1.15.11
|
||||||
resolution: "@swc/core-win32-arm64-msvc@npm:1.11.29"
|
resolution: "@swc/core-win32-arm64-msvc@npm:1.15.11"
|
||||||
conditions: os=win32 & cpu=arm64
|
conditions: os=win32 & cpu=arm64
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@swc/core-win32-ia32-msvc@npm:1.11.29":
|
"@swc/core-win32-ia32-msvc@npm:1.15.11":
|
||||||
version: 1.11.29
|
version: 1.15.11
|
||||||
resolution: "@swc/core-win32-ia32-msvc@npm:1.11.29"
|
resolution: "@swc/core-win32-ia32-msvc@npm:1.15.11"
|
||||||
conditions: os=win32 & cpu=ia32
|
conditions: os=win32 & cpu=ia32
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@swc/core-win32-x64-msvc@npm:1.11.29":
|
"@swc/core-win32-x64-msvc@npm:1.15.11":
|
||||||
version: 1.11.29
|
version: 1.15.11
|
||||||
resolution: "@swc/core-win32-x64-msvc@npm:1.11.29"
|
resolution: "@swc/core-win32-x64-msvc@npm:1.15.11"
|
||||||
conditions: os=win32 & cpu=x64
|
conditions: os=win32 & cpu=x64
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@swc/core@npm:^1.10.1, @swc/core@npm:^1.11.21":
|
"@swc/core@npm:^1.10.1, @swc/core@npm:^1.15.11":
|
||||||
version: 1.11.29
|
version: 1.15.11
|
||||||
resolution: "@swc/core@npm:1.11.29"
|
resolution: "@swc/core@npm:1.15.11"
|
||||||
dependencies:
|
dependencies:
|
||||||
"@swc/core-darwin-arm64": "npm:1.11.29"
|
"@swc/core-darwin-arm64": "npm:1.15.11"
|
||||||
"@swc/core-darwin-x64": "npm:1.11.29"
|
"@swc/core-darwin-x64": "npm:1.15.11"
|
||||||
"@swc/core-linux-arm-gnueabihf": "npm:1.11.29"
|
"@swc/core-linux-arm-gnueabihf": "npm:1.15.11"
|
||||||
"@swc/core-linux-arm64-gnu": "npm:1.11.29"
|
"@swc/core-linux-arm64-gnu": "npm:1.15.11"
|
||||||
"@swc/core-linux-arm64-musl": "npm:1.11.29"
|
"@swc/core-linux-arm64-musl": "npm:1.15.11"
|
||||||
"@swc/core-linux-x64-gnu": "npm:1.11.29"
|
"@swc/core-linux-x64-gnu": "npm:1.15.11"
|
||||||
"@swc/core-linux-x64-musl": "npm:1.11.29"
|
"@swc/core-linux-x64-musl": "npm:1.15.11"
|
||||||
"@swc/core-win32-arm64-msvc": "npm:1.11.29"
|
"@swc/core-win32-arm64-msvc": "npm:1.15.11"
|
||||||
"@swc/core-win32-ia32-msvc": "npm:1.11.29"
|
"@swc/core-win32-ia32-msvc": "npm:1.15.11"
|
||||||
"@swc/core-win32-x64-msvc": "npm:1.11.29"
|
"@swc/core-win32-x64-msvc": "npm:1.15.11"
|
||||||
"@swc/counter": "npm:^0.1.3"
|
"@swc/counter": "npm:^0.1.3"
|
||||||
"@swc/types": "npm:^0.1.21"
|
"@swc/types": "npm:^0.1.25"
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
"@swc/helpers": ">=0.5.17"
|
"@swc/helpers": ">=0.5.17"
|
||||||
dependenciesMeta:
|
dependenciesMeta:
|
||||||
@@ -15929,7 +15936,7 @@ __metadata:
|
|||||||
peerDependenciesMeta:
|
peerDependenciesMeta:
|
||||||
"@swc/helpers":
|
"@swc/helpers":
|
||||||
optional: true
|
optional: true
|
||||||
checksum: 10/6945229bf6da91adff26033910e8e02ccc457a8229724d0539a0b32995d05949c7709cb9cae2cd7ab10cf4d346b235e22dd4d6b207ded765597304e21e6b6101
|
checksum: 10/2ee702f6ee39fc68f1e4d03a19191eaa3762d54ab917d5617741196bbe3beba9fb50b1e878af2735f8a42ecdef3632f44acc090611ebf01a0df4dc533a71f5d2
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
@@ -15958,12 +15965,12 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@swc/types@npm:^0.1.21":
|
"@swc/types@npm:^0.1.25":
|
||||||
version: 0.1.21
|
version: 0.1.25
|
||||||
resolution: "@swc/types@npm:0.1.21"
|
resolution: "@swc/types@npm:0.1.25"
|
||||||
dependencies:
|
dependencies:
|
||||||
"@swc/counter": "npm:^0.1.3"
|
"@swc/counter": "npm:^0.1.3"
|
||||||
checksum: 10/6554bf5c78519f49099a2ba448d170191a14b1c7a35df848f10ee4d6c03ecd681e5213884905187de1d1d221589ec8b5cb77f477d099dc1627c3ec9d7f2fcdb0
|
checksum: 10/f6741450224892d12df43e5ca7f3cc0287df644dcd672626eb0cc2a3a8e3e875f4b29eb11336f37c7240cf6e010ba59eb3a79f4fb8bee5cbd168dfc1326ff369
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
@@ -18163,14 +18170,15 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@vitejs/plugin-react-swc@npm:^3.7.2":
|
"@vitejs/plugin-react-swc@npm:^4.0.0":
|
||||||
version: 3.9.0
|
version: 4.2.3
|
||||||
resolution: "@vitejs/plugin-react-swc@npm:3.9.0"
|
resolution: "@vitejs/plugin-react-swc@npm:4.2.3"
|
||||||
dependencies:
|
dependencies:
|
||||||
"@swc/core": "npm:^1.11.21"
|
"@rolldown/pluginutils": "npm:1.0.0-rc.2"
|
||||||
|
"@swc/core": "npm:^1.15.11"
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
vite: ^4 || ^5 || ^6
|
vite: ^4 || ^5 || ^6 || ^7
|
||||||
checksum: 10/545dddee3c2f7f35f37c680f79bebb98f3968209470ec56c594556410d498b41cf86df60d2ab9a56c69b02bef12ee3198371becc804b85172ec97ee0d2d7633d
|
checksum: 10/48ab3de0a3833987ff7fc15d4561d930853acf1a2e44523279bc877f8ee81a368465f4b32f21155986986538514cc6aad3dfef62eb25490acde3593c970da521
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user