Compare commits

..

9 Commits

Author SHA1 Message Date
DarkSky
3ad482351b fix: server init (#14412)
#### PR Dependency Tree


* **PR #14412** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Refactor**
  * Improved internal code organization for better maintainability.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-02-10 16:18:22 +08:00
DarkSky
03b1d15a8f chore: adjust resource 2026-02-10 14:41:43 +08:00
renovate[bot]
52c7b04a01 chore: bump up @vitejs/plugin-react-swc version to v4 (#14405)
This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
|
[@vitejs/plugin-react-swc](https://redirect.github.com/vitejs/vite-plugin-react/tree/main/packages/plugin-react-swc#readme)
([source](https://redirect.github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react-swc))
| [`^3.7.2` →
`^4.0.0`](https://renovatebot.com/diffs/npm/@vitejs%2fplugin-react-swc/3.9.0/4.2.3)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/@vitejs%2fplugin-react-swc/4.2.3?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@vitejs%2fplugin-react-swc/3.9.0/4.2.3?slim=true)
|

---

### Release Notes

<details>
<summary>vitejs/vite-plugin-react
(@&#8203;vitejs/plugin-react-swc)</summary>

###
[`v4.2.3`](https://redirect.github.com/vitejs/vite-plugin-react/blob/HEAD/packages/plugin-react-swc/CHANGELOG.md#423-2026-02-02)

[Compare
Source](5e600a31ec...12914fa8c1)

###
[`v4.2.2`](https://redirect.github.com/vitejs/vite-plugin-react/blob/HEAD/packages/plugin-react-swc/CHANGELOG.md#422-2025-11-12)

[Compare
Source](https://redirect.github.com/vitejs/vite-plugin-react/compare/v4.2.1...5e600a31ec27fae54df58a46ef1fffa80238042e)

##### Update code to support newer `rolldown-vite`
([#&#8203;978](https://redirect.github.com/vitejs/vite-plugin-react/pull/978))

`rolldown-vite` will remove `optimizeDeps.rollupOptions` in favor of
`optimizeDeps.rolldownOptions` soon. This plugin now uses
`optimizeDeps.rolldownOptions` to support newer `rolldown-vite`. Please
update `rolldown-vite` to the latest version if you are using an older
version.

###
[`v4.2.1`](https://redirect.github.com/vitejs/vite-plugin-react/blob/HEAD/packages/plugin-react-swc/CHANGELOG.md#421-2025-11-05)

[Compare
Source](https://redirect.github.com/vitejs/vite-plugin-react/compare/v4.2.0...v4.2.1)

##### Fix `@vitejs/plugin-react-swc/preamble` on build
([#&#8203;962](https://redirect.github.com/vitejs/vite-plugin-react/pull/962))

###
[`v4.2.0`](https://redirect.github.com/vitejs/vite-plugin-react/blob/HEAD/packages/plugin-react-swc/CHANGELOG.md#420-2025-10-24)

[Compare
Source](https://redirect.github.com/vitejs/vite-plugin-react/compare/v4.1.0...v4.2.0)

##### Add `@vitejs/plugin-react-swc/preamble` virtual module for SSR HMR
([#&#8203;890](https://redirect.github.com/vitejs/vite-plugin-react/pull/890))

SSR applications can now initialize HMR runtime by importing
`@vitejs/plugin-react-swc/preamble` at the top of their client entry
instead of manually calling `transformIndexHtml`. This simplifies SSR
setup for applications that don't use the `transformIndexHtml` API.

##### Use SWC when useAtYourOwnRisk\_mutateSwcOptions is provided
([#&#8203;951](https://redirect.github.com/vitejs/vite-plugin-react/pull/951))

Previously, this plugin did not use SWC if plugins were not provided
even if `useAtYourOwnRisk_mutateSwcOptions` was provided. This is now
fixed.

###
[`v4.1.0`](https://redirect.github.com/vitejs/vite-plugin-react/blob/HEAD/packages/plugin-react-swc/CHANGELOG.md#410-2025-09-17)

[Compare
Source](f21864b102...v4.1.0)

##### Set SWC cacheRoot options

This is set to `{viteCacheDir}/swc` and override the default of `.swc`.

##### Perf: simplify refresh wrapper generation
([#&#8203;835](https://redirect.github.com/vitejs/vite-plugin-react/pull/835))

###
[`v4.0.1`](https://redirect.github.com/vitejs/vite-plugin-react/blob/HEAD/packages/plugin-react-swc/CHANGELOG.md#401-2025-08-19)

[Compare
Source](590f394c1e...f21864b102d40fca4f70dfe9112a10101ec12f54)

##### Set `optimizeDeps.rollupOptions.transform.jsx` instead of
`optimizeDeps.rollupOptions.jsx` for rolldown-vite
([#&#8203;735](https://redirect.github.com/vitejs/vite-plugin-react/pull/735))

`optimizeDeps.rollupOptions.jsx` is going to be deprecated in favor of
`optimizeDeps.rollupOptions.transform.jsx`.

###
[`v4.0.0`](https://redirect.github.com/vitejs/vite-plugin-react/blob/HEAD/packages/plugin-react-swc/CHANGELOG.md#400-2025-08-07)

[Compare
Source](9e0c103895...590f394c1e451987258ed64a4b5fb6207c5e8261)

###
[`v3.11.0`](https://redirect.github.com/vitejs/vite-plugin-react/blob/HEAD/packages/plugin-react-swc/CHANGELOG.md#3110-2025-07-18)

[Compare
Source](32d49ecf9b...9e0c1038959e828865be810a164a51c3db1ac375)

##### Add HMR support for compound components
([#&#8203;518](https://redirect.github.com/vitejs/vite-plugin-react/pull/518))

HMR now works for compound components like this:

```tsx
const Root = () => <div>Accordion Root</div>
const Item = () => <div>Accordion Item</div>

export const Accordion = { Root, Item }
```

##### Return `Plugin[]` instead of `PluginOption[]`
([#&#8203;537](https://redirect.github.com/vitejs/vite-plugin-react/pull/537))

The return type has changed from `react(): PluginOption[]` to more
specialized type `react(): Plugin[]`. This allows for type-safe
manipulation of plugins, for example:

```tsx
// previously this causes type errors
react()
  .map(p => ({ ...p, applyToEnvironment: e => e.name === 'client' }))
```

###
[`v3.10.2`](https://redirect.github.com/vitejs/vite-plugin-react/blob/HEAD/packages/plugin-react-swc/CHANGELOG.md#3102-2025-06-10)

[Compare
Source](8ce7183265...32d49ecf9b15e3070c7abe5a176252a3fe542e5c)

##### Suggest `@vitejs/plugin-react-oxc` if rolldown-vite is detected
[#&#8203;491](https://redirect.github.com/vitejs/vite-plugin-react/pull/491)

Emit a log which recommends `@vitejs/plugin-react-oxc` when
`rolldown-vite` is detected to improve performance and use Oxc under the
hood. The warning can be disabled by setting `disableOxcRecommendation:
true` in the plugin options.

##### Use `optimizeDeps.rollupOptions` instead of
`optimizeDeps.esbuildOptions` for rolldown-vite
[#&#8203;489](https://redirect.github.com/vitejs/vite-plugin-react/pull/489)

This suppresses the warning about `optimizeDeps.esbuildOptions` being
deprecated in rolldown-vite.

##### Add Vite 7-beta to peerDependencies range
[#&#8203;497](https://redirect.github.com/vitejs/vite-plugin-react/pull/497)

React plugins are compatible with Vite 7, this removes the warning when
testing the beta.

###
[`v3.10.1`](https://redirect.github.com/vitejs/vite-plugin-react/blob/HEAD/packages/plugin-react-swc/CHANGELOG.md#3101-2025-06-03)

[Compare
Source](dcadcfc284...8ce7183265c43f88623655a9cfdcec5282068f9b)

##### Add explicit semicolon in preambleCode
[#&#8203;485](https://redirect.github.com/vitejs/vite-plugin-react/pull/485)

This fixes an edge case when using HTML minifiers that strips line
breaks aggressively.

###
[`v3.10.0`](https://redirect.github.com/vitejs/vite-plugin-react/blob/HEAD/packages/plugin-react-swc/CHANGELOG.md#3100-2025-05-23)

[Compare
Source](4a944487aa...dcadcfc2841c0bedfe44279c556835c350dfa5fa)

##### Add `filter` for rolldown-vite
[#&#8203;470](https://redirect.github.com/vitejs/vite-plugin-react/pull/470)

Added `filter` so that it is more performant when running this plugin
with rolldown-powered version of Vite.

##### Skip HMR preamble in Vitest browser mode
[#&#8203;478](https://redirect.github.com/vitejs/vite-plugin-react/pull/478)

This was causing annoying `Sourcemap for "/@&#8203;react-refresh" points
to missing source files` and is unnecessary in test mode.

##### Skip HMR for JSX files with hooks
[#&#8203;480](https://redirect.github.com/vitejs/vite-plugin-react/pull/480)

This removes the HMR warning for hooks with JSX.

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined),
Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/toeverything/AFFiNE).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0Mi45NS4yIiwidXBkYXRlZEluVmVyIjoiNDIuOTUuMiIsInRhcmdldEJyYW5jaCI6ImNhbmFyeSIsImxhYmVscyI6WyJkZXBlbmRlbmNpZXMiXX0=-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-10 03:22:15 +00:00
renovate[bot]
1c0f873c9d chore: bump up RevenueCat/purchases-ios-spm version to from: "5.58.0" (#14399)
This PR contains the following updates:

| Package | Update | Change |
|---|---|---|
|
[RevenueCat/purchases-ios-spm](https://redirect.github.com/RevenueCat/purchases-ios-spm)
| minor | `from: "5.56.1"` → `from: "5.58.0"` |

---

> [!WARNING]
> Some dependencies could not be looked up. Check the Dependency
Dashboard for more information.

---

### Release Notes

<details>
<summary>RevenueCat/purchases-ios-spm
(RevenueCat/purchases-ios-spm)</summary>

###
[`v5.58.0`](https://redirect.github.com/RevenueCat/purchases-ios-spm/compare/5.57.2...5.58.0)

[Compare
Source](https://redirect.github.com/RevenueCat/purchases-ios-spm/compare/5.57.2...5.58.0)

###
[`v5.57.2`](https://redirect.github.com/RevenueCat/purchases-ios-spm/blob/HEAD/CHANGELOG.md#5572)

[Compare
Source](https://redirect.github.com/RevenueCat/purchases-ios-spm/compare/5.57.1...5.57.2)

##### 🔄 Other Changes

- Make networkName nullable in ad event data types
([#&#8203;6229](https://redirect.github.com/RevenueCat/purchases-ios-spm/issues/6229))
via Pol Miro ([@&#8203;polmiro](https://redirect.github.com/polmiro))
- Remove networkName from AdFailedToLoad event
([#&#8203;6208](https://redirect.github.com/RevenueCat/purchases-ios-spm/issues/6208))
via Pol Miro ([@&#8203;polmiro](https://redirect.github.com/polmiro))
- Excluding xcarchive and separate dSYMs folder from XCFramework in
order to reduce download size
([#&#8203;5967](https://redirect.github.com/RevenueCat/purchases-ios-spm/issues/5967))
via Rick ([@&#8203;rickvdl](https://redirect.github.com/rickvdl))

###
[`v5.57.1`](https://redirect.github.com/RevenueCat/purchases-ios-spm/blob/HEAD/CHANGELOG.md#5571)

[Compare
Source](https://redirect.github.com/RevenueCat/purchases-ios-spm/compare/5.57.0...5.57.1)

#### 5.57.1

###
[`v5.57.0`](https://redirect.github.com/RevenueCat/purchases-ios-spm/blob/HEAD/CHANGELOG.md#5570)

[Compare
Source](https://redirect.github.com/RevenueCat/purchases-ios-spm/compare/5.56.1...5.57.0)

#### 5.57.0

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined),
Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/toeverything/AFFiNE).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0Mi45NS4yIiwidXBkYXRlZEluVmVyIjoiNDIuOTUuMiIsInRhcmdldEJyYW5jaCI6ImNhbmFyeSIsImxhYmVscyI6WyJkZXBlbmRlbmNpZXMiXX0=-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-09 00:41:34 +08:00
DarkSky
8b68574820 fix: old workspace migration (#14403)
fix #14395 

#### PR Dependency Tree


* **PR #14403** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
  * Added ability to enumerate and list local workspaces.
* Improved workspace ID persistence with Electron global-state storage,
automatic fallback to legacy storage, and one-time migration to
consolidate IDs.
* **Tests**
* Added unit test validating listing behavior (includes/excludes
workspaces based on presence of workspace DB file).
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-02-09 00:22:37 +08:00
DarkSky
bb01bb1aef fix(editor): database behavier (#14394)
fix #13459
fix #13707
fix #13924

#### PR Dependency Tree


* **PR #14394** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Improved URL paste: text is split into segments, inserted correctly,
and single-URL pastes create linked-page references.

* **UI Improvements**
  * Redesigned layout selector with compact dynamic options.
* Number-format options are always available in table headers and mobile
menus.

* **Bug Fixes**
  * More consistent paste behavior for mixed text+URL content.
  * Prevented recursive selection updates when exiting edit mode.

* **Tests**
* Added tests for URL splitting, paste insertion, number formatting, and
selection behavior.

* **Chores**
* Removed number-formatting feature flag; formatting now applied by
default.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-02-08 23:31:30 +08:00
DarkSky
8192a492d9 feat: improve kanban grouping & data materialization (#14393)
fix #13512 
fix #13255
fix #9743 

#### PR Dependency Tree


* **PR #14393** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Enhanced Kanban view grouping support for additional property types:
checkboxes, select fields, multi-select fields, members, and created-by
information.
* Improved drag-and-drop visual feedback with more precise drop
indicators in Kanban views.

* **Bug Fixes**
* Refined grouping logic to ensure only compatible properties appear in
group-by options.
* Enhanced column visibility and ordering consistency when managing
Kanban views.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-02-08 03:48:12 +08:00
DarkSky
31e11b2563 chore: polish config & docs (#14392)
#### PR Dependency Tree


* **PR #14392** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)
2026-02-08 01:16:00 +08:00
DarkSky
5a36acea7b chore: adjust resource 2026-02-07 17:59:14 +08:00
52 changed files with 1767 additions and 584 deletions

View File

@@ -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,

View File

@@ -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

View File

@@ -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

View File

@@ -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:

View File

@@ -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

View File

@@ -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"
} }

View File

@@ -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 youre 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 youre made of.
@@ -169,8 +152,10 @@ Welcome to the AFFiNE blog section! Here, youll 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

View File

@@ -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 }]);
});
});

View File

@@ -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,
}; };
}); });

View File

@@ -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,
});
}

View File

@@ -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() {

View File

@@ -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) {

View 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();
}
});
});
});

View File

@@ -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);
});
});

View 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);
});
});

View File

@@ -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>();

View File

@@ -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;
} }
} }

View File

@@ -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),
}), }),

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;
} }

View File

@@ -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: () => ({}),

View File

@@ -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: () => ({

View File

@@ -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

View File

@@ -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,

View File

@@ -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: () => ({

View File

@@ -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,

View File

@@ -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'

View File

@@ -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),
};
};

View File

@@ -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];

View File

@@ -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();

View File

@@ -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,

View File

@@ -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: [

View File

@@ -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: [

View File

@@ -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: [

View File

@@ -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: {

View File

@@ -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',
},
]);
});
});

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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",

View File

@@ -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;

View File

@@ -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(),
}; };

View File

@@ -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');

View File

@@ -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(

View File

@@ -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: () => ({}),

View File

@@ -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: () => ({}),

View File

@@ -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);
} }

View File

@@ -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)
);
} }

View File

@@ -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',
},
]

View File

@@ -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
View File

@@ -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