mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-16 13:57:02 +08:00
Compare commits
18 Commits
v0.26.1
...
v2026.2.14
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
819402d9f1 | ||
|
|
33bc3e2fe9 | ||
|
|
2b71b3f345 | ||
|
|
3bc28ba78c | ||
|
|
72df9cb457 | ||
|
|
98e5747fdc | ||
|
|
4460604dd3 | ||
|
|
b4be9118ad | ||
|
|
b46bf91575 | ||
|
|
3ad482351b | ||
|
|
03b1d15a8f | ||
|
|
52c7b04a01 | ||
|
|
1c0f873c9d | ||
|
|
8b68574820 | ||
|
|
bb01bb1aef | ||
|
|
8192a492d9 | ||
|
|
31e11b2563 | ||
|
|
5a36acea7b |
33
.github/actions/deploy/deploy.mjs
vendored
33
.github/actions/deploy/deploy.mjs
vendored
@@ -25,30 +25,30 @@ const buildType = BUILD_TYPE || 'canary';
|
||||
|
||||
const isProduction = buildType === 'stable';
|
||||
const isBeta = buildType === 'beta';
|
||||
const isCanary = buildType === 'canary';
|
||||
const isInternal = buildType === 'internal';
|
||||
const isSpotEnabled = isBeta || isCanary;
|
||||
|
||||
const replicaConfig = {
|
||||
stable: {
|
||||
front: Number(process.env.PRODUCTION_FRONT_REPLICA) || 2,
|
||||
graphql: Number(process.env.PRODUCTION_GRAPHQL_REPLICA) || 2,
|
||||
doc: Number(process.env.PRODUCTION_DOC_REPLICA) || 2,
|
||||
},
|
||||
beta: {
|
||||
front: Number(process.env.BETA_FRONT_REPLICA) || 1,
|
||||
graphql: Number(process.env.BETA_GRAPHQL_REPLICA) || 1,
|
||||
doc: Number(process.env.BETA_DOC_REPLICA) || 1,
|
||||
},
|
||||
canary: { front: 1, graphql: 1, doc: 1 },
|
||||
canary: { front: 1, graphql: 1 },
|
||||
};
|
||||
|
||||
const cpuConfig = {
|
||||
beta: { front: '1', graphql: '1', doc: '1' },
|
||||
canary: { front: '500m', graphql: '1', doc: '500m' },
|
||||
beta: { front: '1', graphql: '1' },
|
||||
canary: { front: '500m', graphql: '1' },
|
||||
};
|
||||
|
||||
const memoryConfig = {
|
||||
beta: { front: '1Gi', graphql: '1Gi', doc: '1Gi' },
|
||||
canary: { front: '512Mi', graphql: '512Mi', doc: '512Mi' },
|
||||
beta: { front: '2Gi', graphql: '1Gi' },
|
||||
canary: { front: '512Mi', graphql: '512Mi' },
|
||||
};
|
||||
|
||||
const createHelmCommand = ({ isDryRun }) => {
|
||||
@@ -72,10 +72,12 @@ const createHelmCommand = ({ isDryRun }) => {
|
||||
`--set-string global.indexer.endpoint="${AFFINE_INDEXER_SEARCH_ENDPOINT}"`,
|
||||
`--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 = [
|
||||
`--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 doc.serviceAccount.annotations="{ \\"iam.gke.io/gcp-service-account\\": \\"${APP_IAM_ACCOUNT}\\" }"`,
|
||||
].concat(
|
||||
isProduction || isBeta || isInternal
|
||||
? [
|
||||
@@ -84,10 +86,17 @@ const createHelmCommand = ({ isDryRun }) => {
|
||||
`--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 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}"`,
|
||||
]
|
||||
: [];
|
||||
|
||||
const cpu = cpuConfig[buildType];
|
||||
const memory = memoryConfig[buildType];
|
||||
@@ -96,14 +105,12 @@ const createHelmCommand = ({ isDryRun }) => {
|
||||
resources = resources.concat([
|
||||
`--set front.resources.requests.cpu="${cpu.front}"`,
|
||||
`--set graphql.resources.requests.cpu="${cpu.graphql}"`,
|
||||
`--set doc.resources.requests.cpu="${cpu.doc}"`,
|
||||
]);
|
||||
}
|
||||
if (memory) {
|
||||
resources = resources.concat([
|
||||
`--set front.resources.requests.memory="${memory.front}"`,
|
||||
`--set graphql.resources.requests.memory="${memory.graphql}"`,
|
||||
`--set doc.resources.requests.memory="${memory.doc}"`,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -142,10 +149,8 @@ const createHelmCommand = ({ isDryRun }) => {
|
||||
`--set graphql.replicaCount=${replica.graphql}`,
|
||||
`--set-string graphql.image.tag="${imageTag}"`,
|
||||
`--set-string graphql.app.host="${primaryHost}"`,
|
||||
`--set-string doc.image.tag="${imageTag}"`,
|
||||
`--set-string doc.app.host="${primaryHost}"`,
|
||||
`--set doc.replicaCount=${replica.doc}`,
|
||||
...serviceAnnotations,
|
||||
...spotScheduling,
|
||||
...resources,
|
||||
`--timeout 10m`,
|
||||
flag,
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
1. Get the application URL by running these commands:
|
||||
{{- if contains "NodePort" .Values.service.type }}
|
||||
export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "doc.fullname" . }})
|
||||
export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}")
|
||||
echo http://$NODE_IP:$NODE_PORT
|
||||
{{- else if contains "LoadBalancer" .Values.service.type }}
|
||||
NOTE: It may take a few minutes for the LoadBalancer IP to be available.
|
||||
You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "doc.fullname" . }}'
|
||||
export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "doc.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}")
|
||||
echo http://$SERVICE_IP:{{ .Values.service.port }}
|
||||
{{- else if contains "ClusterIP" .Values.service.type }}
|
||||
export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "doc.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}")
|
||||
export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}")
|
||||
echo "Visit http://127.0.0.1:8080 to use your application"
|
||||
kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT
|
||||
{{- end }}
|
||||
@@ -1,63 +0,0 @@
|
||||
{{/*
|
||||
Expand the name of the chart.
|
||||
*/}}
|
||||
{{- define "doc.name" -}}
|
||||
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Create a default fully qualified app name.
|
||||
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
|
||||
If release name contains chart name it will be used as a full name.
|
||||
*/}}
|
||||
{{- define "doc.fullname" -}}
|
||||
{{- if .Values.fullnameOverride }}
|
||||
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
|
||||
{{- else }}
|
||||
{{- $name := default .Chart.Name .Values.nameOverride }}
|
||||
{{- if contains $name .Release.Name }}
|
||||
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
|
||||
{{- else }}
|
||||
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Create chart name and version as used by the chart label.
|
||||
*/}}
|
||||
{{- define "doc.chart" -}}
|
||||
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Common labels
|
||||
*/}}
|
||||
{{- define "doc.labels" -}}
|
||||
helm.sh/chart: {{ include "doc.chart" . }}
|
||||
{{ include "doc.selectorLabels" . }}
|
||||
{{- if .Chart.AppVersion }}
|
||||
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
|
||||
{{- end }}
|
||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||
monitoring: enabled
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Selector labels
|
||||
*/}}
|
||||
{{- define "doc.selectorLabels" -}}
|
||||
app.kubernetes.io/name: {{ include "doc.name" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Create the name of the service account to use
|
||||
*/}}
|
||||
{{- define "doc.serviceAccountName" -}}
|
||||
{{- if .Values.serviceAccount.create }}
|
||||
{{- default (include "doc.fullname" .) .Values.global.docService.name }}
|
||||
{{- else }}
|
||||
{{- default "default" .Values.global.docService.name }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -1,118 +0,0 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ include "doc.fullname" . }}
|
||||
labels:
|
||||
{{- include "doc.labels" . | nindent 4 }}
|
||||
spec:
|
||||
replicas: {{ .Values.replicaCount }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "doc.selectorLabels" . | nindent 6 }}
|
||||
template:
|
||||
metadata:
|
||||
{{- with .Values.podAnnotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
labels:
|
||||
{{- include "doc.selectorLabels" . | nindent 8 }}
|
||||
spec:
|
||||
{{- with .Values.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
serviceAccountName: {{ include "doc.serviceAccountName" . }}
|
||||
containers:
|
||||
- name: {{ .Chart.Name }}
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
|
||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||
env:
|
||||
- name: AFFINE_PRIVATE_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: "{{ .Values.global.secret.secretName }}"
|
||||
key: key
|
||||
- name: NODE_ENV
|
||||
value: "{{ .Values.env }}"
|
||||
- name: NODE_OPTIONS
|
||||
value: "--max-old-space-size=4096"
|
||||
- name: NO_COLOR
|
||||
value: "1"
|
||||
- name: DEPLOYMENT_TYPE
|
||||
value: "{{ .Values.global.deployment.type }}"
|
||||
- name: DEPLOYMENT_PLATFORM
|
||||
value: "{{ .Values.global.deployment.platform }}"
|
||||
- name: SERVER_FLAVOR
|
||||
value: "doc"
|
||||
- name: AFFINE_ENV
|
||||
value: "{{ .Release.Namespace }}"
|
||||
- name: DATABASE_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: pg-postgresql
|
||||
key: postgres-password
|
||||
- name: DATABASE_URL
|
||||
value: postgres://{{ .Values.global.database.user }}:$(DATABASE_PASSWORD)@{{ .Values.global.database.host }}:{{ .Values.global.database.port }}/{{ .Values.global.database.name }}
|
||||
- name: REDIS_SERVER_ENABLED
|
||||
value: "true"
|
||||
- name: REDIS_SERVER_HOST
|
||||
value: "{{ .Values.global.redis.host }}"
|
||||
- name: REDIS_SERVER_PORT
|
||||
value: "{{ .Values.global.redis.port }}"
|
||||
- name: REDIS_SERVER_USER
|
||||
value: "{{ .Values.global.redis.username }}"
|
||||
- name: REDIS_SERVER_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: redis
|
||||
key: redis-password
|
||||
- name: REDIS_SERVER_DATABASE
|
||||
value: "{{ .Values.global.redis.database }}"
|
||||
- name: AFFINE_INDEXER_SEARCH_PROVIDER
|
||||
value: "{{ .Values.global.indexer.provider }}"
|
||||
- name: AFFINE_INDEXER_SEARCH_ENDPOINT
|
||||
value: "{{ .Values.global.indexer.endpoint }}"
|
||||
- name: AFFINE_INDEXER_SEARCH_API_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: indexer
|
||||
key: indexer-apiKey
|
||||
- name: AFFINE_SERVER_PORT
|
||||
value: "{{ .Values.global.docService.port }}"
|
||||
- name: AFFINE_SERVER_SUB_PATH
|
||||
value: "{{ .Values.app.path }}"
|
||||
- name: AFFINE_SERVER_HOST
|
||||
value: "{{ .Values.app.host }}"
|
||||
- name: AFFINE_SERVER_HTTPS
|
||||
value: "{{ .Values.app.https }}"
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: {{ .Values.global.docService.port }}
|
||||
protocol: TCP
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /info
|
||||
port: http
|
||||
initialDelaySeconds: {{ .Values.probe.initialDelaySeconds }}
|
||||
timeoutSeconds: {{ .Values.probe.timeoutSeconds }}
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /info
|
||||
port: http
|
||||
initialDelaySeconds: {{ .Values.probe.initialDelaySeconds }}
|
||||
timeoutSeconds: {{ .Values.probe.timeoutSeconds }}
|
||||
resources:
|
||||
{{- toYaml .Values.resources | nindent 12 }}
|
||||
{{- with .Values.nodeSelector }}
|
||||
nodeSelector:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.affinity }}
|
||||
affinity:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.tolerations }}
|
||||
tolerations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
@@ -1,12 +0,0 @@
|
||||
{{- if .Values.serviceAccount.create -}}
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: {{ include "doc.serviceAccountName" . }}
|
||||
labels:
|
||||
{{- include "doc.labels" . | nindent 4 }}
|
||||
{{- with .Values.serviceAccount.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -1,15 +0,0 @@
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata:
|
||||
name: "{{ include "doc.fullname" . }}-test-connection"
|
||||
labels:
|
||||
{{- include "doc.labels" . | nindent 4 }}
|
||||
annotations:
|
||||
"helm.sh/hook": test
|
||||
spec:
|
||||
containers:
|
||||
- name: wget
|
||||
image: busybox
|
||||
command: ['wget']
|
||||
args: ['{{ include "doc.fullname" . }}:{{ .Values.global.docService.port }}']
|
||||
restartPolicy: Never
|
||||
5
.github/helm/affine/charts/doc/values.yaml
vendored
5
.github/helm/affine/charts/doc/values.yaml
vendored
@@ -30,9 +30,12 @@ podSecurityContext:
|
||||
fsGroup: 2000
|
||||
|
||||
resources:
|
||||
requests:
|
||||
limits:
|
||||
cpu: '1'
|
||||
memory: 4Gi
|
||||
requests:
|
||||
cpu: '1'
|
||||
memory: 2Gi
|
||||
|
||||
probe:
|
||||
initialDelaySeconds: 20
|
||||
|
||||
@@ -88,8 +88,6 @@ spec:
|
||||
value: "{{ .Values.app.host }}"
|
||||
- name: AFFINE_SERVER_HTTPS
|
||||
value: "{{ .Values.app.https }}"
|
||||
- name: DOC_SERVICE_ENDPOINT
|
||||
value: "http://{{ .Values.global.docService.name }}:{{ .Values.global.docService.port }}"
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: {{ .Values.app.port }}
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ include "doc.fullname" . }}
|
||||
name: {{ .Values.global.docService.name }}
|
||||
labels:
|
||||
{{- include "doc.labels" . | nindent 4 }}
|
||||
{{- with .Values.service.annotations }}
|
||||
{{- include "front.labels" . | nindent 4 }}
|
||||
{{- with .Values.services.doc.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
type: {{ .Values.service.type }}
|
||||
type: {{ .Values.services.doc.type }}
|
||||
ports:
|
||||
- port: {{ .Values.global.docService.port }}
|
||||
targetPort: http
|
||||
protocol: TCP
|
||||
name: http
|
||||
selector:
|
||||
{{- include "doc.selectorLabels" . | nindent 4 }}
|
||||
{{- include "front.selectorLabels" . | nindent 4 }}
|
||||
10
.github/helm/affine/charts/front/values.yaml
vendored
10
.github/helm/affine/charts/front/values.yaml
vendored
@@ -29,9 +29,12 @@ podSecurityContext:
|
||||
fsGroup: 2000
|
||||
|
||||
resources:
|
||||
limits:
|
||||
cpu: '1'
|
||||
memory: 2Gi
|
||||
requests:
|
||||
cpu: '2'
|
||||
memory: 4Gi
|
||||
cpu: '1'
|
||||
memory: 2Gi
|
||||
|
||||
probe:
|
||||
initialDelaySeconds: 20
|
||||
@@ -54,6 +57,9 @@ services:
|
||||
type: ClusterIP
|
||||
port: 8080
|
||||
annotations: {}
|
||||
doc:
|
||||
type: ClusterIP
|
||||
annotations: {}
|
||||
|
||||
nodeSelector: {}
|
||||
tolerations: []
|
||||
|
||||
@@ -27,8 +27,11 @@ podSecurityContext:
|
||||
fsGroup: 2000
|
||||
|
||||
resources:
|
||||
limits:
|
||||
cpu: '1'
|
||||
memory: 4Gi
|
||||
requests:
|
||||
cpu: '2'
|
||||
cpu: '1'
|
||||
memory: 2Gi
|
||||
|
||||
probe:
|
||||
|
||||
10
.github/helm/affine/values.yaml
vendored
10
.github/helm/affine/values.yaml
vendored
@@ -47,12 +47,6 @@ graphql:
|
||||
annotations:
|
||||
cloud.google.com/backend-config: '{"default": "affine-api-backendconfig"}'
|
||||
|
||||
doc:
|
||||
service:
|
||||
type: ClusterIP
|
||||
annotations:
|
||||
cloud.google.com/backend-config: '{"default": "affine-api-backendconfig"}'
|
||||
|
||||
front:
|
||||
services:
|
||||
sync:
|
||||
@@ -71,3 +65,7 @@ front:
|
||||
name: affine-web
|
||||
type: ClusterIP
|
||||
port: 8080
|
||||
doc:
|
||||
type: ClusterIP
|
||||
annotations:
|
||||
cloud.google.com/backend-config: '{"default": "affine-api-backendconfig"}'
|
||||
|
||||
6
.github/workflows/auto-labeler.yml
vendored
6
.github/workflows/auto-labeler.yml
vendored
@@ -1,6 +1,10 @@
|
||||
name: 'Pull Request Labeler'
|
||||
on:
|
||||
- pull_request_target
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
- reopened
|
||||
- synchronize
|
||||
|
||||
jobs:
|
||||
triage:
|
||||
|
||||
296
.github/workflows/build-test.yml
vendored
296
.github/workflows/build-test.yml
vendored
@@ -210,18 +210,13 @@ jobs:
|
||||
e2e-blocksuite-cross-browser-test:
|
||||
name: E2E BlockSuite Cross Browser Test
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1]
|
||||
browser: ['chromium', 'firefox', 'webkit']
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Node.js
|
||||
uses: ./.github/actions/setup-node
|
||||
with:
|
||||
playwright-install: true
|
||||
playwright-platform: ${{ matrix.browser }}
|
||||
playwright-platform: 'chromium,firefox,webkit'
|
||||
electron-install: false
|
||||
full-cache: true
|
||||
|
||||
@@ -229,18 +224,64 @@ jobs:
|
||||
run: yarn workspace @blocksuite/playground build
|
||||
|
||||
- name: Run playwright tests
|
||||
env:
|
||||
BROWSER: ${{ matrix.browser }}
|
||||
run: yarn workspace @affine-test/blocksuite test "cross-platform/" --forbid-only --shard=${{ matrix.shard }}/${{ strategy.job-total }}
|
||||
run: |
|
||||
yarn workspace @blocksuite/integration-test test:unit
|
||||
yarn workspace @affine-test/blocksuite test "cross-platform/" --forbid-only
|
||||
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: test-results-e2e-bs-cross-browser-${{ matrix.browser }}-${{ matrix.shard }}
|
||||
name: test-results-e2e-bs-cross-browser
|
||||
path: ./test-results
|
||||
if-no-files-found: ignore
|
||||
|
||||
bundler-matrix:
|
||||
name: Bundler Matrix (${{ matrix.bundler }})
|
||||
runs-on: ubuntu-24.04-arm
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
bundler: [webpack, rspack]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Node.js
|
||||
uses: ./.github/actions/setup-node
|
||||
with:
|
||||
playwright-install: false
|
||||
electron-install: false
|
||||
full-cache: true
|
||||
|
||||
- name: Run frontend build matrix
|
||||
env:
|
||||
AFFINE_BUNDLER: ${{ matrix.bundler }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
packages=(
|
||||
"@affine/web"
|
||||
"@affine/mobile"
|
||||
"@affine/ios"
|
||||
"@affine/android"
|
||||
"@affine/admin"
|
||||
"@affine/electron-renderer"
|
||||
)
|
||||
summary="test-results-bundler-${AFFINE_BUNDLER}.txt"
|
||||
: > "$summary"
|
||||
for pkg in "${packages[@]}"; do
|
||||
start=$(date +%s)
|
||||
yarn affine "$pkg" build
|
||||
end=$(date +%s)
|
||||
echo "${pkg},$((end-start))" >> "$summary"
|
||||
done
|
||||
|
||||
- name: Upload bundler timing
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: test-results-bundler-${{ matrix.bundler }}
|
||||
path: ./test-results-bundler-${{ matrix.bundler }}.txt
|
||||
if-no-files-found: ignore
|
||||
|
||||
e2e-test:
|
||||
name: E2E Test
|
||||
runs-on: ubuntu-24.04-arm
|
||||
@@ -307,7 +348,7 @@ jobs:
|
||||
name: Unit Test
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- build-native
|
||||
- build-native-linux
|
||||
env:
|
||||
DISTRIBUTION: web
|
||||
strategy:
|
||||
@@ -321,6 +362,7 @@ jobs:
|
||||
with:
|
||||
electron-install: true
|
||||
playwright-install: true
|
||||
playwright-platform: 'chromium,firefox,webkit'
|
||||
full-cache: true
|
||||
|
||||
- name: Download affine.linux-x64-gnu.node
|
||||
@@ -341,7 +383,39 @@ jobs:
|
||||
name: affine
|
||||
fail_ci_if_error: false
|
||||
|
||||
build-native:
|
||||
build-native-linux:
|
||||
name: Build AFFiNE native (x86_64-unknown-linux-gnu)
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
CARGO_PROFILE_RELEASE_DEBUG: '1'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Node.js
|
||||
uses: ./.github/actions/setup-node
|
||||
with:
|
||||
extra-flags: workspaces focus @affine/native
|
||||
electron-install: false
|
||||
- name: Setup filename
|
||||
id: filename
|
||||
working-directory: ${{ github.workspace }}
|
||||
shell: bash
|
||||
run: |
|
||||
export PLATFORM_ARCH_ABI=$(node -e "console.log(require('@napi-rs/cli').parseTriple('x86_64-unknown-linux-gnu').platformArchABI)")
|
||||
echo "filename=affine.$PLATFORM_ARCH_ABI.node" >> "$GITHUB_OUTPUT"
|
||||
- name: Build AFFiNE native
|
||||
uses: ./.github/actions/build-rust
|
||||
with:
|
||||
target: x86_64-unknown-linux-gnu
|
||||
package: '@affine/native'
|
||||
- name: Upload ${{ steps.filename.outputs.filename }}
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: ${{ steps.filename.outputs.filename }}
|
||||
path: ${{ github.workspace }}/packages/frontend/native/${{ steps.filename.outputs.filename }}
|
||||
if-no-files-found: error
|
||||
|
||||
build-native-macos:
|
||||
name: Build AFFiNE native (${{ matrix.spec.target }})
|
||||
runs-on: ${{ matrix.spec.os }}
|
||||
env:
|
||||
@@ -350,7 +424,6 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
spec:
|
||||
- { os: ubuntu-latest, target: x86_64-unknown-linux-gnu }
|
||||
- { os: macos-latest, target: x86_64-apple-darwin }
|
||||
- { os: macos-latest, target: aarch64-apple-darwin }
|
||||
|
||||
@@ -383,7 +456,7 @@ jobs:
|
||||
|
||||
# Split Windows build because it's too slow
|
||||
# and other ci jobs required linux native
|
||||
build-windows-native:
|
||||
build-native-windows:
|
||||
name: Build AFFiNE native (${{ matrix.spec.target }})
|
||||
runs-on: ${{ matrix.spec.os }}
|
||||
env:
|
||||
@@ -483,7 +556,7 @@ jobs:
|
||||
name: Native Unit Test
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- build-native
|
||||
- build-native-linux
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Node.js
|
||||
@@ -577,8 +650,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- build-server-native
|
||||
strategy:
|
||||
fail-fast: false
|
||||
env:
|
||||
NODE_ENV: test
|
||||
DATABASE_URL: postgresql://affine:affine@localhost:5432/affine
|
||||
@@ -819,11 +890,51 @@ jobs:
|
||||
- name: Run tests
|
||||
run: cargo nextest run --workspace --exclude affine_server_native --features use-as-lib --release --no-fail-fast
|
||||
|
||||
copilot-test-filter:
|
||||
name: Copilot test filter
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
run-api: ${{ steps.decision.outputs.run_api }}
|
||||
run-e2e: ${{ steps.decision.outputs.run_e2e }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: dorny/paths-filter@v3
|
||||
id: copilot-filter
|
||||
with:
|
||||
filters: |
|
||||
api:
|
||||
- 'packages/backend/server/src/plugins/copilot/**'
|
||||
- 'packages/backend/server/tests/copilot.*'
|
||||
e2e:
|
||||
- 'packages/backend/server/src/plugins/copilot/**'
|
||||
- 'packages/backend/server/tests/copilot.*'
|
||||
- 'packages/frontend/core/src/blocksuite/ai/**'
|
||||
- 'packages/frontend/core/src/modules/workspace-indexer-embedding/**'
|
||||
- 'tests/affine-cloud-copilot/**'
|
||||
|
||||
- name: Decide test scope
|
||||
id: decision
|
||||
run: |
|
||||
if [[ "${{ steps.copilot-filter.outputs.api }}" == "true" ]]; then
|
||||
echo "run_api=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "run_api=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
if [[ "${{ steps.copilot-filter.outputs.e2e }}" == "true" ]]; then
|
||||
echo "run_e2e=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "run_e2e=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
copilot-api-test:
|
||||
name: Server Copilot Api Test
|
||||
if: ${{ needs.copilot-test-filter.outputs.run-api == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- build-server-native
|
||||
- copilot-test-filter
|
||||
env:
|
||||
NODE_ENV: test
|
||||
DISTRIBUTION: web
|
||||
@@ -857,53 +968,29 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Check blocksuite update
|
||||
id: check-blocksuite-update
|
||||
env:
|
||||
BASE_REF: ${{ github.base_ref }}
|
||||
run: |
|
||||
if node ./scripts/detect-blocksuite-update.mjs "$BASE_REF"; then
|
||||
echo "skip=false" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "skip=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- uses: dorny/paths-filter@v3
|
||||
id: apifilter
|
||||
with:
|
||||
filters: |
|
||||
changed:
|
||||
- 'packages/backend/server/src/plugins/copilot/**'
|
||||
- 'packages/backend/server/tests/copilot.*'
|
||||
|
||||
- name: Setup Node.js
|
||||
if: ${{ steps.check-blocksuite-update.outputs.skip != 'true' || steps.apifilter.outputs.changed == 'true' }}
|
||||
uses: ./.github/actions/setup-node
|
||||
with:
|
||||
electron-install: false
|
||||
full-cache: true
|
||||
|
||||
- name: Download server-native.node
|
||||
if: ${{ steps.check-blocksuite-update.outputs.skip != 'true' || steps.apifilter.outputs.changed == 'true' }}
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: server-native.node
|
||||
path: ./packages/backend/native
|
||||
|
||||
- name: Prepare Server Test Environment
|
||||
if: ${{ steps.check-blocksuite-update.outputs.skip != 'true' || steps.apifilter.outputs.changed == 'true' }}
|
||||
env:
|
||||
SERVER_CONFIG: ${{ secrets.TEST_SERVER_CONFIG }}
|
||||
uses: ./.github/actions/server-test-env
|
||||
|
||||
- name: Run server tests
|
||||
if: ${{ steps.check-blocksuite-update.outputs.skip != 'true' || steps.apifilter.outputs.changed == 'true' }}
|
||||
run: yarn affine @affine/server test:copilot:coverage --forbid-only
|
||||
env:
|
||||
CARGO_TARGET_DIR: '${{ github.workspace }}/target'
|
||||
|
||||
- name: Upload server test coverage results
|
||||
if: ${{ steps.check-blocksuite-update.outputs.skip != 'true' || steps.apifilter.outputs.changed == 'true' }}
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
@@ -914,6 +1001,7 @@ jobs:
|
||||
|
||||
copilot-e2e-test:
|
||||
name: Frontend Copilot E2E Test
|
||||
if: ${{ needs.copilot-test-filter.outputs.run-e2e == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
DISTRIBUTION: web
|
||||
@@ -928,6 +1016,7 @@ jobs:
|
||||
shardTotal: [5]
|
||||
needs:
|
||||
- build-server-native
|
||||
- copilot-test-filter
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg16
|
||||
@@ -951,30 +1040,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Check blocksuite update
|
||||
id: check-blocksuite-update
|
||||
env:
|
||||
BASE_REF: ${{ github.base_ref }}
|
||||
run: |
|
||||
if node ./scripts/detect-blocksuite-update.mjs "$BASE_REF"; then
|
||||
echo "skip=false" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "skip=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- uses: dorny/paths-filter@v3
|
||||
id: e2efilter
|
||||
with:
|
||||
filters: |
|
||||
changed:
|
||||
- 'packages/backend/server/src/plugins/copilot/**'
|
||||
- 'packages/backend/server/tests/copilot.*'
|
||||
- 'packages/frontend/core/src/blocksuite/ai/**'
|
||||
- 'packages/frontend/core/src/modules/workspace-indexer-embedding/**'
|
||||
- 'tests/affine-cloud-copilot/**'
|
||||
|
||||
- name: Setup Node.js
|
||||
if: ${{ steps.check-blocksuite-update.outputs.skip != 'true' || steps.e2efilter.outputs.changed == 'true' }}
|
||||
uses: ./.github/actions/setup-node
|
||||
with:
|
||||
playwright-install: true
|
||||
@@ -983,20 +1049,17 @@ jobs:
|
||||
hard-link-nm: false
|
||||
|
||||
- name: Download server-native.node
|
||||
if: ${{ steps.check-blocksuite-update.outputs.skip != 'true' || steps.e2efilter.outputs.changed == 'true' }}
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: server-native.node
|
||||
path: ./packages/backend/native
|
||||
|
||||
- name: Prepare Server Test Environment
|
||||
if: ${{ steps.check-blocksuite-update.outputs.skip != 'true' || steps.e2efilter.outputs.changed == 'true' }}
|
||||
env:
|
||||
SERVER_CONFIG: ${{ secrets.TEST_SERVER_CONFIG }}
|
||||
uses: ./.github/actions/server-test-env
|
||||
|
||||
- name: Run Copilot E2E Test ${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
|
||||
if: ${{ steps.check-blocksuite-update.outputs.skip != 'true' || steps.e2efilter.outputs.changed == 'true' }}
|
||||
uses: ./.github/actions/copilot-test
|
||||
with:
|
||||
script: yarn affine @affine-test/affine-cloud-copilot e2e --forbid-only --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
|
||||
@@ -1006,7 +1069,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- build-server-native
|
||||
- build-native
|
||||
- build-native-linux
|
||||
env:
|
||||
DISTRIBUTION: web
|
||||
DATABASE_URL: postgresql://affine:affine@localhost:5432/affine
|
||||
@@ -1099,7 +1162,9 @@ jobs:
|
||||
runs-on: ${{ matrix.spec.os }}
|
||||
needs:
|
||||
- build-electron-renderer
|
||||
- build-native
|
||||
- build-native-linux
|
||||
- build-native-macos
|
||||
- build-native-windows
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -1182,84 +1247,6 @@ jobs:
|
||||
if: ${{ matrix.spec.test && matrix.spec.os != 'ubuntu-latest' }}
|
||||
run: yarn affine @affine-test/affine-desktop e2e
|
||||
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: test-results-e2e-${{ matrix.spec.os }}-${{ matrix.spec.arch }}
|
||||
path: ./test-results
|
||||
if-no-files-found: ignore
|
||||
|
||||
desktop-bundle-check:
|
||||
name: Desktop bundle check (${{ matrix.spec.os }}, ${{ matrix.spec.platform }}, ${{ matrix.spec.arch }}, ${{ matrix.spec.target }}, ${{ matrix.spec.test }})
|
||||
runs-on: ${{ matrix.spec.os }}
|
||||
needs:
|
||||
- build-electron-renderer
|
||||
- build-native
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
spec:
|
||||
- {
|
||||
os: macos-latest,
|
||||
platform: macos,
|
||||
arch: x64,
|
||||
target: x86_64-apple-darwin,
|
||||
test: false,
|
||||
}
|
||||
- {
|
||||
os: macos-latest,
|
||||
platform: macos,
|
||||
arch: arm64,
|
||||
target: aarch64-apple-darwin,
|
||||
test: true,
|
||||
}
|
||||
- {
|
||||
os: ubuntu-latest,
|
||||
platform: linux,
|
||||
arch: x64,
|
||||
target: x86_64-unknown-linux-gnu,
|
||||
test: true,
|
||||
}
|
||||
- {
|
||||
os: windows-latest,
|
||||
platform: windows,
|
||||
arch: x64,
|
||||
target: x86_64-pc-windows-msvc,
|
||||
test: true,
|
||||
}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Node.js
|
||||
uses: ./.github/actions/setup-node
|
||||
timeout-minutes: 10
|
||||
with:
|
||||
extra-flags: workspaces focus @affine/electron @affine/monorepo @affine-test/affine-desktop @affine/nbstore @toeverything/infra
|
||||
playwright-install: true
|
||||
hard-link-nm: false
|
||||
enableScripts: false
|
||||
|
||||
- name: Setup filename
|
||||
id: filename
|
||||
shell: bash
|
||||
run: |
|
||||
export PLATFORM_ARCH_ABI=$(node -e "console.log(require('@napi-rs/cli').parseTriple('${{ matrix.spec.target }}').platformArchABI)")
|
||||
echo "filename=affine.$PLATFORM_ARCH_ABI.node" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Download ${{ steps.filename.outputs.filename }}
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: ${{ steps.filename.outputs.filename }}
|
||||
path: ./packages/frontend/native
|
||||
|
||||
- name: Download web artifact
|
||||
uses: ./.github/actions/download-web
|
||||
with:
|
||||
path: packages/frontend/apps/electron/resources/web-static
|
||||
|
||||
- name: Build Desktop Layers
|
||||
run: yarn affine @affine/electron build
|
||||
|
||||
- name: Make bundle (macOS)
|
||||
if: ${{ matrix.spec.target == 'aarch64-apple-darwin' }}
|
||||
env:
|
||||
@@ -1299,6 +1286,14 @@ jobs:
|
||||
run: |
|
||||
yarn affine @affine/electron node ./scripts/macos-arm64-output-check.ts
|
||||
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: test-results-e2e-${{ matrix.spec.os }}-${{ matrix.spec.arch }}
|
||||
path: ./test-results
|
||||
if-no-files-found: ignore
|
||||
|
||||
test-done:
|
||||
needs:
|
||||
- analyze
|
||||
@@ -1312,8 +1307,9 @@ jobs:
|
||||
- e2e-blocksuite-cross-browser-test
|
||||
- e2e-mobile-test
|
||||
- unit-test
|
||||
- build-native
|
||||
- build-windows-native
|
||||
- build-native-linux
|
||||
- build-native-macos
|
||||
- build-native-windows
|
||||
- build-server-native
|
||||
- build-electron-renderer
|
||||
- native-unit-test
|
||||
@@ -1323,10 +1319,10 @@ jobs:
|
||||
- server-test
|
||||
- server-e2e-test
|
||||
- rust-test
|
||||
- copilot-test-filter
|
||||
- copilot-api-test
|
||||
- copilot-e2e-test
|
||||
- desktop-test
|
||||
- desktop-bundle-check
|
||||
- cloud-e2e-test
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
1
.github/workflows/pr-title-lint.yml
vendored
1
.github/workflows/pr-title-lint.yml
vendored
@@ -16,6 +16,7 @@ jobs:
|
||||
check-pull-request-title:
|
||||
name: Check pull request title
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.action != 'edited' || github.event.changes.title != null }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Node.js
|
||||
|
||||
72
.github/workflows/sync-i18n.yml
vendored
72
.github/workflows/sync-i18n.yml
vendored
@@ -1,72 +0,0 @@
|
||||
name: Sync I18n with Crowdin
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- canary
|
||||
paths:
|
||||
- 'packages/frontend/i18n/**'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
synchronize-with-crowdin:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Crowdin action
|
||||
id: crowdin
|
||||
uses: crowdin/github-action@v2
|
||||
with:
|
||||
upload_sources: true
|
||||
upload_translations: false
|
||||
download_translations: true
|
||||
auto_approve_imported: true
|
||||
import_eq_suggestions: true
|
||||
export_only_approved: true
|
||||
skip_untranslated_strings: true
|
||||
localization_branch_name: l10n_crowdin_translations
|
||||
create_pull_request: true
|
||||
pull_request_title: 'chore(i18n): sync translations'
|
||||
pull_request_body: 'New Crowdin translations by [Crowdin GH Action](https://github.com/crowdin/github-action)'
|
||||
pull_request_base_branch_name: 'canary'
|
||||
config: packages/frontend/i18n/crowdin.yml
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
i18n-codegen:
|
||||
needs: synchronize-with-crowdin
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: l10n_crowdin_translations
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: ./.github/actions/setup-node
|
||||
with:
|
||||
electron-install: false
|
||||
full-cache: true
|
||||
|
||||
- name: Run i18n codegen
|
||||
run: yarn affine @affine/i18n build
|
||||
|
||||
- name: Commit changes
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add .
|
||||
git commit -m "chore(i18n): i18n codegen"
|
||||
git push origin l10n_crowdin_translations
|
||||
13
.vscode/settings.template.json
vendored
13
.vscode/settings.template.json
vendored
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"eslint.packageManager": "yarn",
|
||||
"prisma.pinToPrisma6": true,
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true,
|
||||
"editor.formatOnSaveMode": "file",
|
||||
@@ -14,11 +14,13 @@
|
||||
"testid",
|
||||
"schemars"
|
||||
],
|
||||
"explorer.fileNesting.enabled": true,
|
||||
"explorer.fileNesting.patterns": {
|
||||
"*.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",
|
||||
"Cargo.toml": "Cargo.lock",
|
||||
"README.md": "LICENSE, CHANGELOG.md, CODE_OF_CONDUCT.md, CONTRIBUTING.md"
|
||||
"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, rust-toolchain*, rustfmt.toml, .taplo.toml",
|
||||
"README.md": "LICENSE*, CHANGELOG.md, CODE_OF_CONDUCT.md, CONTRIBUTING.md, SECURITY.md, README.*",
|
||||
".gitignore": ".gitattributes, .dockerignore, .eslintignore, .prettierignore, .stylelintignore, .tslintignore, .yarnignore"
|
||||
},
|
||||
"[rust]": {
|
||||
"editor.defaultFormatter": "rust-lang.rust-analyzer"
|
||||
@@ -32,5 +34,6 @@
|
||||
"vitest.include": ["packages/**/*.spec.ts", "packages/**/*.spec.tsx"],
|
||||
"rust-analyzer.check.extraEnv": {
|
||||
"DATABASE_URL": "sqlite:affine.db"
|
||||
}
|
||||
},
|
||||
"typescript.tsdk": "node_modules/typescript/lib"
|
||||
}
|
||||
|
||||
35
README.md
35
README.md
@@ -21,23 +21,6 @@
|
||||
<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">
|
||||
<a href="https://affine.pro">Home Page</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
|
||||
|
||||
| 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) |
|
||||
| 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 |
|
||||
| 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) | [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 |
|
||||
|
||||
Calling all developers, testers, tech writers and more! Contributions of all types are more than welcome, you can read more in [docs/types-of-contributions.md](docs/types-of-contributions.md). If you are interested in contributing code, read our [docs/CONTRIBUTING.md](docs/CONTRIBUTING.md) and feel free to check out our GitHub issues to get stuck in to show us what you’re made of.
|
||||
|
||||
@@ -169,8 +152,10 @@ Welcome to the AFFiNE blog section! Here, you’ll find the latest insights, tip
|
||||
We would also like to give thanks to open-source projects that make AFFiNE possible:
|
||||
|
||||
- [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.
|
||||
- [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.
|
||||
- [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.
|
||||
@@ -221,12 +206,6 @@ See [BUILDING.md] for instructions on how to build AFFiNE from source code.
|
||||
We welcome contributions from everyone.
|
||||
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
|
||||
|
||||
### Editions
|
||||
|
||||
@@ -2101,6 +2101,157 @@ describe('html to snapshot', () => {
|
||||
expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot);
|
||||
});
|
||||
|
||||
test('paragraph with br should split into multiple blocks', async () => {
|
||||
const html = template(`<p>aaa<br>bbb<br>ccc</p>`);
|
||||
|
||||
const blockSnapshot: BlockSnapshot = {
|
||||
type: 'block',
|
||||
id: 'matchesReplaceMap[0]',
|
||||
flavour: 'affine:note',
|
||||
props: {
|
||||
xywh: '[0,0,800,95]',
|
||||
background: DefaultTheme.noteBackgrounColor,
|
||||
index: 'a0',
|
||||
hidden: false,
|
||||
displayMode: NoteDisplayMode.DocAndEdgeless,
|
||||
},
|
||||
children: [
|
||||
{
|
||||
type: 'block',
|
||||
id: 'matchesReplaceMap[1]',
|
||||
flavour: 'affine:paragraph',
|
||||
props: {
|
||||
type: 'text',
|
||||
text: {
|
||||
'$blocksuite:internal:text$': true,
|
||||
delta: [{ insert: 'aaa' }],
|
||||
},
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
type: 'block',
|
||||
id: 'matchesReplaceMap[2]',
|
||||
flavour: 'affine:paragraph',
|
||||
props: {
|
||||
type: 'text',
|
||||
text: {
|
||||
'$blocksuite:internal:text$': true,
|
||||
delta: [{ insert: 'bbb' }],
|
||||
},
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
type: 'block',
|
||||
id: 'matchesReplaceMap[3]',
|
||||
flavour: 'affine:paragraph',
|
||||
props: {
|
||||
type: 'text',
|
||||
text: {
|
||||
'$blocksuite:internal:text$': true,
|
||||
delta: [{ insert: 'ccc' }],
|
||||
},
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const htmlAdapter = new HtmlAdapter(createJob(), provider);
|
||||
const rawBlockSnapshot = await htmlAdapter.toBlockSnapshot({
|
||||
file: html,
|
||||
});
|
||||
expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot);
|
||||
});
|
||||
|
||||
test('paragraph with br should keep inline styles in each split line', async () => {
|
||||
const html = template(
|
||||
`<p><strong>aaa</strong><br><a href="https://www.google.com/">bbb</a><br><em>ccc</em></p>`
|
||||
);
|
||||
|
||||
const blockSnapshot: BlockSnapshot = {
|
||||
type: 'block',
|
||||
id: 'matchesReplaceMap[0]',
|
||||
flavour: 'affine:note',
|
||||
props: {
|
||||
xywh: '[0,0,800,95]',
|
||||
background: DefaultTheme.noteBackgrounColor,
|
||||
index: 'a0',
|
||||
hidden: false,
|
||||
displayMode: NoteDisplayMode.DocAndEdgeless,
|
||||
},
|
||||
children: [
|
||||
{
|
||||
type: 'block',
|
||||
id: 'matchesReplaceMap[1]',
|
||||
flavour: 'affine:paragraph',
|
||||
props: {
|
||||
type: 'text',
|
||||
text: {
|
||||
'$blocksuite:internal:text$': true,
|
||||
delta: [
|
||||
{
|
||||
insert: 'aaa',
|
||||
attributes: {
|
||||
bold: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
type: 'block',
|
||||
id: 'matchesReplaceMap[2]',
|
||||
flavour: 'affine:paragraph',
|
||||
props: {
|
||||
type: 'text',
|
||||
text: {
|
||||
'$blocksuite:internal:text$': true,
|
||||
delta: [
|
||||
{
|
||||
insert: 'bbb',
|
||||
attributes: {
|
||||
link: 'https://www.google.com/',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
type: 'block',
|
||||
id: 'matchesReplaceMap[3]',
|
||||
flavour: 'affine:paragraph',
|
||||
props: {
|
||||
type: 'text',
|
||||
text: {
|
||||
'$blocksuite:internal:text$': true,
|
||||
delta: [
|
||||
{
|
||||
insert: 'ccc',
|
||||
attributes: {
|
||||
italic: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const htmlAdapter = new HtmlAdapter(createJob(), provider);
|
||||
const rawBlockSnapshot = await htmlAdapter.toBlockSnapshot({
|
||||
file: html,
|
||||
});
|
||||
expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot);
|
||||
});
|
||||
|
||||
test('nested list', async () => {
|
||||
const html = template(`<ul><li>111<ul><li>222</li></ul></li></ul>`);
|
||||
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import { insertUrlTextSegments } from '../../../../blocks/database/src/properties/paste-url.js';
|
||||
|
||||
type InsertCall = {
|
||||
range: {
|
||||
index: number;
|
||||
length: number;
|
||||
};
|
||||
text: string;
|
||||
attributes?: AffineTextAttributes;
|
||||
};
|
||||
|
||||
describe('insertUrlTextSegments', () => {
|
||||
test('should replace selected text on first insert and append remaining segments', () => {
|
||||
const insertCalls: InsertCall[] = [];
|
||||
const selectionCalls: Array<{ index: number; length: number } | null> = [];
|
||||
const inlineEditor = {
|
||||
insertText: (
|
||||
range: { index: number; length: number },
|
||||
text: string,
|
||||
attributes?: AffineTextAttributes
|
||||
) => {
|
||||
insertCalls.push({ range, text, attributes });
|
||||
},
|
||||
setInlineRange: (range: { index: number; length: number } | null) => {
|
||||
selectionCalls.push(range);
|
||||
},
|
||||
};
|
||||
|
||||
const inlineRange = { index: 4, length: 6 };
|
||||
const segments = [
|
||||
{ text: 'hi - ' },
|
||||
{ text: 'https://google.com', link: 'https://google.com' },
|
||||
];
|
||||
|
||||
insertUrlTextSegments(inlineEditor, inlineRange, segments);
|
||||
|
||||
expect(insertCalls).toEqual([
|
||||
{
|
||||
range: { index: 4, length: 6 },
|
||||
text: 'hi - ',
|
||||
},
|
||||
{
|
||||
range: { index: 9, length: 0 },
|
||||
text: 'https://google.com',
|
||||
attributes: {
|
||||
link: 'https://google.com',
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(selectionCalls).toEqual([{ index: 27, length: 0 }]);
|
||||
});
|
||||
|
||||
test('should keep insertion range length zero when there is no selected text', () => {
|
||||
const insertCalls: InsertCall[] = [];
|
||||
const selectionCalls: Array<{ index: number; length: number } | null> = [];
|
||||
const inlineEditor = {
|
||||
insertText: (
|
||||
range: { index: number; length: number },
|
||||
text: string,
|
||||
attributes?: AffineTextAttributes
|
||||
) => {
|
||||
insertCalls.push({ range, text, attributes });
|
||||
},
|
||||
setInlineRange: (range: { index: number; length: number } | null) => {
|
||||
selectionCalls.push(range);
|
||||
},
|
||||
};
|
||||
|
||||
const inlineRange = { index: 2, length: 0 };
|
||||
const segments = [
|
||||
{ text: 'prefix ' },
|
||||
{ text: 'https://a.com', link: 'https://a.com' },
|
||||
];
|
||||
|
||||
insertUrlTextSegments(inlineEditor, inlineRange, segments);
|
||||
|
||||
expect(insertCalls).toEqual([
|
||||
{
|
||||
range: { index: 2, length: 0 },
|
||||
text: 'prefix ',
|
||||
},
|
||||
{
|
||||
range: { index: 9, length: 0 },
|
||||
text: 'https://a.com',
|
||||
attributes: {
|
||||
link: 'https://a.com',
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(selectionCalls).toEqual([{ index: 22, length: 0 }]);
|
||||
});
|
||||
});
|
||||
@@ -135,14 +135,10 @@ export class DatabaseBlockDataSource extends DataSourceBase {
|
||||
|
||||
override featureFlags$: ReadonlySignal<DatabaseFlags> = computed(() => {
|
||||
const featureFlagService = this.doc.get(FeatureFlagService);
|
||||
const enableNumberFormat = featureFlagService.getFlag(
|
||||
'enable_database_number_formatting'
|
||||
);
|
||||
const enableTableVirtualScroll = featureFlagService.getFlag(
|
||||
'enable_table_virtual_scroll'
|
||||
);
|
||||
return {
|
||||
enable_number_formatting: enableNumberFormat ?? false,
|
||||
enable_table_virtual_scroll: enableTableVirtualScroll ?? false,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import type {
|
||||
AffineInlineEditor,
|
||||
AffineTextAttributes,
|
||||
} from '@blocksuite/affine-shared/types';
|
||||
import {
|
||||
splitTextByUrl,
|
||||
type UrlTextSegment,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import type { InlineRange } from '@blocksuite/std/inline';
|
||||
|
||||
type UrlPasteInlineEditor = Pick<
|
||||
AffineInlineEditor,
|
||||
'insertText' | 'setInlineRange'
|
||||
>;
|
||||
|
||||
export function analyzeTextForUrlPaste(text: string) {
|
||||
const segments = splitTextByUrl(text);
|
||||
const firstSegment = segments[0];
|
||||
const singleUrl =
|
||||
segments.length === 1 && firstSegment?.link && firstSegment.text === text
|
||||
? firstSegment.link
|
||||
: undefined;
|
||||
return {
|
||||
segments,
|
||||
singleUrl,
|
||||
};
|
||||
}
|
||||
|
||||
export function insertUrlTextSegments(
|
||||
inlineEditor: UrlPasteInlineEditor,
|
||||
inlineRange: InlineRange,
|
||||
segments: UrlTextSegment[]
|
||||
) {
|
||||
let index = inlineRange.index;
|
||||
let replacedSelection = false;
|
||||
segments.forEach(segment => {
|
||||
if (!segment.text) return;
|
||||
const attributes: AffineTextAttributes | undefined = segment.link
|
||||
? { link: segment.link }
|
||||
: undefined;
|
||||
inlineEditor.insertText(
|
||||
{
|
||||
index,
|
||||
length: replacedSelection ? 0 : inlineRange.length,
|
||||
},
|
||||
segment.text,
|
||||
attributes
|
||||
);
|
||||
replacedSelection = true;
|
||||
index += segment.text.length;
|
||||
});
|
||||
inlineEditor.setInlineRange({
|
||||
index,
|
||||
length: 0,
|
||||
});
|
||||
}
|
||||
@@ -8,10 +8,7 @@ import type {
|
||||
AffineInlineEditor,
|
||||
AffineTextAttributes,
|
||||
} from '@blocksuite/affine-shared/types';
|
||||
import {
|
||||
getViewportElement,
|
||||
isValidUrl,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import { getViewportElement } from '@blocksuite/affine-shared/utils';
|
||||
import {
|
||||
BaseCellRenderer,
|
||||
createFromBaseCellRenderer,
|
||||
@@ -26,6 +23,7 @@ import { html } from 'lit/static-html.js';
|
||||
|
||||
import { EditorHostKey } from '../../context/host-context.js';
|
||||
import type { DatabaseBlockComponent } from '../../database-block.js';
|
||||
import { analyzeTextForUrlPaste, insertUrlTextSegments } from '../paste-url.js';
|
||||
import {
|
||||
richTextCellStyle,
|
||||
richTextContainerStyle,
|
||||
@@ -271,10 +269,13 @@ export class RichTextCell extends BaseCellRenderer<Text, string> {
|
||||
?.getData('text/plain')
|
||||
?.replace(/\r?\n|\r/g, '\n');
|
||||
if (!text) return;
|
||||
const { segments, singleUrl } = analyzeTextForUrlPaste(text);
|
||||
|
||||
if (isValidUrl(text)) {
|
||||
if (singleUrl) {
|
||||
const std = this.std;
|
||||
const result = std?.getOptional(ParseDocUrlProvider)?.parseDocUrl(text);
|
||||
const result = std
|
||||
?.getOptional(ParseDocUrlProvider)
|
||||
?.parseDocUrl(singleUrl);
|
||||
if (result) {
|
||||
const text = ' ';
|
||||
inlineEditor.insertText(inlineRange, text, {
|
||||
@@ -300,22 +301,10 @@ export class RichTextCell extends BaseCellRenderer<Text, string> {
|
||||
segment: 'database',
|
||||
parentFlavour: 'affine:database',
|
||||
});
|
||||
} else {
|
||||
inlineEditor.insertText(inlineRange, text, {
|
||||
link: text,
|
||||
});
|
||||
inlineEditor.setInlineRange({
|
||||
index: inlineRange.index + text.length,
|
||||
length: 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
inlineEditor.insertText(inlineRange, text);
|
||||
inlineEditor.setInlineRange({
|
||||
index: inlineRange.index + text.length,
|
||||
length: 0,
|
||||
});
|
||||
}
|
||||
insertUrlTextSegments(inlineEditor, inlineRange, segments);
|
||||
};
|
||||
|
||||
override connectedCallback() {
|
||||
|
||||
@@ -4,10 +4,7 @@ import {
|
||||
ParseDocUrlProvider,
|
||||
TelemetryProvider,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import {
|
||||
getViewportElement,
|
||||
isValidUrl,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import { getViewportElement } from '@blocksuite/affine-shared/utils';
|
||||
import { BaseCellRenderer } from '@blocksuite/data-view';
|
||||
import { IS_MAC } from '@blocksuite/global/env';
|
||||
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 type { DatabaseBlockComponent } from '../../database-block.js';
|
||||
import { getSingleDocIdFromText } from '../../utils/title-doc.js';
|
||||
import { analyzeTextForUrlPaste, insertUrlTextSegments } from '../paste-url.js';
|
||||
import {
|
||||
headerAreaIconStyle,
|
||||
titleCellStyle,
|
||||
@@ -95,7 +93,9 @@ export class HeaderAreaTextCell extends BaseCellRenderer<Text, string> {
|
||||
private readonly _onPaste = (e: ClipboardEvent) => {
|
||||
const inlineEditor = this.inlineEditor;
|
||||
const inlineRange = inlineEditor?.getInlineRange();
|
||||
if (!inlineRange) return;
|
||||
if (!inlineEditor || !inlineRange) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.clipboardData) {
|
||||
try {
|
||||
const getDeltas = (snapshot: BlockSnapshot): DeltaInsert[] => {
|
||||
@@ -121,14 +121,15 @@ export class HeaderAreaTextCell extends BaseCellRenderer<Text, string> {
|
||||
?.getData('text/plain')
|
||||
?.replace(/\r?\n|\r/g, '\n');
|
||||
if (!text) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (isValidUrl(text)) {
|
||||
const { segments, singleUrl } = analyzeTextForUrlPaste(text);
|
||||
if (singleUrl) {
|
||||
const std = this.std;
|
||||
const result = std?.getOptional(ParseDocUrlProvider)?.parseDocUrl(text);
|
||||
const result = std
|
||||
?.getOptional(ParseDocUrlProvider)
|
||||
?.parseDocUrl(singleUrl);
|
||||
if (result) {
|
||||
const text = ' ';
|
||||
inlineEditor?.insertText(inlineRange, text, {
|
||||
inlineEditor.insertText(inlineRange, text, {
|
||||
reference: {
|
||||
type: 'LinkedPage',
|
||||
pageId: result.docId,
|
||||
@@ -139,7 +140,7 @@ export class HeaderAreaTextCell extends BaseCellRenderer<Text, string> {
|
||||
},
|
||||
},
|
||||
});
|
||||
inlineEditor?.setInlineRange({
|
||||
inlineEditor.setInlineRange({
|
||||
index: inlineRange.index + text.length,
|
||||
length: 0,
|
||||
});
|
||||
@@ -151,22 +152,10 @@ export class HeaderAreaTextCell extends BaseCellRenderer<Text, string> {
|
||||
segment: 'database',
|
||||
parentFlavour: 'affine:database',
|
||||
});
|
||||
} else {
|
||||
inlineEditor?.insertText(inlineRange, text, {
|
||||
link: text,
|
||||
});
|
||||
inlineEditor?.setInlineRange({
|
||||
index: inlineRange.index + text.length,
|
||||
length: 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
inlineEditor?.insertText(inlineRange, text);
|
||||
inlineEditor?.setInlineRange({
|
||||
index: inlineRange.index + text.length,
|
||||
length: 0,
|
||||
});
|
||||
}
|
||||
insertUrlTextSegments(inlineEditor, inlineRange, segments);
|
||||
};
|
||||
|
||||
insertDelta = (delta: DeltaInsert) => {
|
||||
@@ -240,7 +229,8 @@ export class HeaderAreaTextCell extends BaseCellRenderer<Text, string> {
|
||||
this.disposables.addFromEvent(
|
||||
this.richText.value,
|
||||
'paste',
|
||||
this._onPaste
|
||||
this._onPaste,
|
||||
true
|
||||
);
|
||||
const inlineEditor = this.inlineEditor;
|
||||
if (inlineEditor) {
|
||||
|
||||
@@ -37,6 +37,126 @@ const tagsInAncestor = (o: NodeProps<HtmlAST>, tagNames: Array<string>) => {
|
||||
return false;
|
||||
};
|
||||
|
||||
const splitDeltaByNewline = (delta: DeltaInsert[]) => {
|
||||
const lines: DeltaInsert[][] = [[]];
|
||||
const pending = [...delta];
|
||||
|
||||
while (pending.length > 0) {
|
||||
const op = pending.shift();
|
||||
if (!op) continue;
|
||||
|
||||
const insert = op.insert;
|
||||
if (typeof insert !== 'string') {
|
||||
lines[lines.length - 1].push(op);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!insert.includes('\n')) {
|
||||
if (insert.length === 0) {
|
||||
continue;
|
||||
}
|
||||
lines[lines.length - 1].push(op);
|
||||
continue;
|
||||
}
|
||||
|
||||
const splitIndex = insert.indexOf('\n');
|
||||
const linePart = insert.slice(0, splitIndex);
|
||||
const remainPart = insert.slice(splitIndex + 1);
|
||||
if (linePart.length > 0) {
|
||||
lines[lines.length - 1].push({ ...op, insert: linePart });
|
||||
}
|
||||
lines.push([]);
|
||||
if (remainPart) {
|
||||
pending.unshift({ ...op, insert: remainPart });
|
||||
}
|
||||
}
|
||||
|
||||
return lines;
|
||||
};
|
||||
|
||||
const hasBlockElementDescendant = (node: HtmlAST): boolean => {
|
||||
if (!HastUtils.isElement(node)) {
|
||||
return false;
|
||||
}
|
||||
return node.children.some(child => {
|
||||
if (!HastUtils.isElement(child)) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
(HastUtils.isTagBlock(child.tagName) && child.tagName !== 'br') ||
|
||||
hasBlockElementDescendant(child)
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const getParagraphDeltas = (
|
||||
node: HtmlAST,
|
||||
delta: DeltaInsert[]
|
||||
): DeltaInsert[][] => {
|
||||
if (!HastUtils.isElement(node)) return [delta];
|
||||
if (hasBlockElementDescendant(node)) return [delta];
|
||||
|
||||
const hasBr = !!HastUtils.querySelector(node, 'br');
|
||||
if (!hasBr) return [delta];
|
||||
|
||||
const hasNewline = delta.some(
|
||||
op => typeof op.insert === 'string' && op.insert.includes('\n')
|
||||
);
|
||||
if (!hasNewline) return [delta];
|
||||
|
||||
return splitDeltaByNewline(delta);
|
||||
};
|
||||
|
||||
const openParagraphBlocks = (
|
||||
deltas: DeltaInsert[][],
|
||||
type: string,
|
||||
// AST walker context from html adapter transform pipeline.
|
||||
walkerContext: any
|
||||
) => {
|
||||
for (const delta of deltas) {
|
||||
walkerContext
|
||||
.openNode(
|
||||
{
|
||||
type: 'block',
|
||||
id: nanoid(),
|
||||
flavour: 'affine:paragraph',
|
||||
props: { type, text: { '$blocksuite:internal:text$': true, delta } },
|
||||
children: [],
|
||||
},
|
||||
'children'
|
||||
)
|
||||
.closeNode();
|
||||
}
|
||||
};
|
||||
|
||||
const MULTI_PARAGRAPH_EMITTED_NODES_CONTEXT_KEY =
|
||||
'affine:paragraph:multi-emitted-nodes';
|
||||
|
||||
const markMultiParagraphEmitted = (walkerContext: any, node: HtmlAST) => {
|
||||
const emittedNodes =
|
||||
(walkerContext.getGlobalContext(
|
||||
MULTI_PARAGRAPH_EMITTED_NODES_CONTEXT_KEY
|
||||
) as WeakSet<object> | undefined) ?? new WeakSet<object>();
|
||||
emittedNodes.add(node as object);
|
||||
walkerContext.setGlobalContext(
|
||||
MULTI_PARAGRAPH_EMITTED_NODES_CONTEXT_KEY,
|
||||
emittedNodes
|
||||
);
|
||||
};
|
||||
|
||||
const consumeMultiParagraphEmittedMark = (
|
||||
walkerContext: any,
|
||||
node: HtmlAST
|
||||
) => {
|
||||
const emittedNodes = walkerContext.getGlobalContext(
|
||||
MULTI_PARAGRAPH_EMITTED_NODES_CONTEXT_KEY
|
||||
) as WeakSet<object> | undefined;
|
||||
if (!emittedNodes) {
|
||||
return false;
|
||||
}
|
||||
return emittedNodes.delete(node as object);
|
||||
};
|
||||
|
||||
export const paragraphBlockHtmlAdapterMatcher: BlockHtmlAdapterMatcher = {
|
||||
flavour: ParagraphBlockSchema.model.flavour,
|
||||
toMatch: o =>
|
||||
@@ -88,41 +208,37 @@ export const paragraphBlockHtmlAdapterMatcher: BlockHtmlAdapterMatcher = {
|
||||
!tagsInAncestor(o, ['p', 'li']) &&
|
||||
HastUtils.isParagraphLike(o.node)
|
||||
) {
|
||||
walkerContext
|
||||
.openNode(
|
||||
{
|
||||
type: 'block',
|
||||
id: nanoid(),
|
||||
flavour: 'affine:paragraph',
|
||||
props: {
|
||||
type: 'text',
|
||||
text: {
|
||||
'$blocksuite:internal:text$': true,
|
||||
delta: deltaConverter.astToDelta(o.node),
|
||||
},
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
'children'
|
||||
)
|
||||
.closeNode();
|
||||
const delta = deltaConverter.astToDelta(o.node);
|
||||
const deltas = getParagraphDeltas(o.node, delta);
|
||||
openParagraphBlocks(deltas, 'text', walkerContext);
|
||||
walkerContext.skipAllChildren();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'p': {
|
||||
const type = walkerContext.getGlobalContext('hast:blockquote')
|
||||
? 'quote'
|
||||
: 'text';
|
||||
const delta = deltaConverter.astToDelta(o.node);
|
||||
const deltas = getParagraphDeltas(o.node, delta);
|
||||
|
||||
if (deltas.length > 1) {
|
||||
openParagraphBlocks(deltas, type, walkerContext);
|
||||
markMultiParagraphEmitted(walkerContext, o.node);
|
||||
walkerContext.skipAllChildren();
|
||||
break;
|
||||
}
|
||||
|
||||
walkerContext.openNode(
|
||||
{
|
||||
type: 'block',
|
||||
id: nanoid(),
|
||||
flavour: 'affine:paragraph',
|
||||
props: {
|
||||
type: walkerContext.getGlobalContext('hast:blockquote')
|
||||
? 'quote'
|
||||
: 'text',
|
||||
type,
|
||||
text: {
|
||||
'$blocksuite:internal:text$': true,
|
||||
delta: deltaConverter.astToDelta(o.node),
|
||||
delta,
|
||||
},
|
||||
},
|
||||
children: [],
|
||||
@@ -192,6 +308,9 @@ export const paragraphBlockHtmlAdapterMatcher: BlockHtmlAdapterMatcher = {
|
||||
break;
|
||||
}
|
||||
case 'p': {
|
||||
if (consumeMultiParagraphEmittedMark(walkerContext, o.node)) {
|
||||
break;
|
||||
}
|
||||
if (
|
||||
o.next?.type === 'element' &&
|
||||
o.next.tagName === 'div' &&
|
||||
|
||||
@@ -86,6 +86,7 @@ export class PageClipboard extends ReadOnlyClipboard {
|
||||
|
||||
if (this.std.store.readonly) return;
|
||||
this.std.store.captureSync();
|
||||
let hasPasteTarget = false;
|
||||
this.std.command
|
||||
.chain()
|
||||
.try<{}>(cmd => [
|
||||
@@ -144,18 +145,39 @@ export class PageClipboard extends ReadOnlyClipboard {
|
||||
if (!ctx.parentBlock) {
|
||||
return;
|
||||
}
|
||||
hasPasteTarget = true;
|
||||
this.std.clipboard
|
||||
.paste(
|
||||
e,
|
||||
this.std.store,
|
||||
ctx.parentBlock.model.id,
|
||||
ctx.blockIndex ? ctx.blockIndex + 1 : 1
|
||||
ctx.blockIndex !== undefined ? ctx.blockIndex + 1 : 1
|
||||
)
|
||||
.catch(console.error);
|
||||
|
||||
return next();
|
||||
})
|
||||
.run();
|
||||
|
||||
if (hasPasteTarget) return;
|
||||
|
||||
// If no valid selection target exists (for example, stale block selection
|
||||
// right after cut), create/focus the default paragraph and paste after it.
|
||||
const firstParagraphId = document
|
||||
.querySelector('affine-page-root')
|
||||
?.focusFirstParagraph?.()?.id;
|
||||
const parentModel = firstParagraphId
|
||||
? this.std.store.getParent(firstParagraphId)
|
||||
: null;
|
||||
const paragraphIndex =
|
||||
firstParagraphId && parentModel
|
||||
? parentModel.children.findIndex(child => child.id === firstParagraphId)
|
||||
: -1;
|
||||
const insertIndex = paragraphIndex >= 0 ? paragraphIndex + 1 : undefined;
|
||||
|
||||
this.std.clipboard
|
||||
.paste(e, this.std.store, parentModel?.id, insertIndex)
|
||||
.catch(console.error);
|
||||
};
|
||||
|
||||
override mounted() {
|
||||
|
||||
517
blocksuite/affine/data-view/src/__tests__/kanban.unit.spec.ts
Normal file
517
blocksuite/affine/data-view/src/__tests__/kanban.unit.spec.ts
Normal file
@@ -0,0 +1,517 @@
|
||||
import { signal } from '@preact/signals-core';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { GroupBy } from '../core/common/types.js';
|
||||
import type { DataSource } from '../core/data-source/base.js';
|
||||
import { DetailSelection } from '../core/detail/selection.js';
|
||||
import { groupByMatchers } from '../core/group-by/define.js';
|
||||
import { t } from '../core/logical/type-presets.js';
|
||||
import type { DataViewCellLifeCycle } from '../core/property/index.js';
|
||||
import { checkboxPropertyModelConfig } from '../property-presets/checkbox/define.js';
|
||||
import { multiSelectPropertyModelConfig } from '../property-presets/multi-select/define.js';
|
||||
import { selectPropertyModelConfig } from '../property-presets/select/define.js';
|
||||
import { textPropertyModelConfig } from '../property-presets/text/define.js';
|
||||
import {
|
||||
canGroupable,
|
||||
ensureKanbanGroupColumn,
|
||||
pickKanbanGroupColumn,
|
||||
resolveKanbanGroupBy,
|
||||
} from '../view-presets/kanban/group-by-utils.js';
|
||||
import { materializeKanbanColumns } from '../view-presets/kanban/kanban-view-manager.js';
|
||||
import type { KanbanCard } from '../view-presets/kanban/pc/card.js';
|
||||
import { KanbanDragController } from '../view-presets/kanban/pc/controller/drag.js';
|
||||
import type { KanbanGroup } from '../view-presets/kanban/pc/group.js';
|
||||
|
||||
type Column = {
|
||||
id: string;
|
||||
type: string;
|
||||
data?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type TestPropertyMeta = {
|
||||
type: string;
|
||||
config: {
|
||||
kanbanGroup?: {
|
||||
enabled: boolean;
|
||||
mutable?: boolean;
|
||||
};
|
||||
propertyData: {
|
||||
default: () => Record<string, unknown>;
|
||||
};
|
||||
jsonValue: {
|
||||
type: (options: {
|
||||
data: Record<string, unknown>;
|
||||
dataSource: DataSource;
|
||||
}) => unknown;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type MockDataSource = {
|
||||
properties$: ReturnType<typeof signal<string[]>>;
|
||||
provider: {
|
||||
getAll: () => Map<unknown, unknown>;
|
||||
};
|
||||
serviceGetOrCreate: (key: unknown, create: () => unknown) => unknown;
|
||||
propertyTypeGet: (propertyId: string) => string | undefined;
|
||||
propertyMetaGet: (type: string) => TestPropertyMeta | undefined;
|
||||
propertyDataGet: (propertyId: string) => Record<string, unknown>;
|
||||
propertyDataTypeGet: (propertyId: string) => unknown;
|
||||
propertyAdd: (
|
||||
_position: unknown,
|
||||
ops?: {
|
||||
type?: string;
|
||||
}
|
||||
) => string;
|
||||
propertyDataSet: (propertyId: string, data: Record<string, unknown>) => void;
|
||||
};
|
||||
|
||||
const asDataSource = (dataSource: object): DataSource =>
|
||||
dataSource as DataSource;
|
||||
|
||||
const toTestMeta = <TData extends Record<string, unknown>>(
|
||||
type: string,
|
||||
config: {
|
||||
kanbanGroup?: {
|
||||
enabled: boolean;
|
||||
mutable?: boolean;
|
||||
};
|
||||
propertyData: {
|
||||
default: () => TData;
|
||||
};
|
||||
jsonValue: {
|
||||
type: (options: { data: TData; dataSource: DataSource }) => unknown;
|
||||
};
|
||||
}
|
||||
): TestPropertyMeta => ({
|
||||
type,
|
||||
config: {
|
||||
kanbanGroup: config.kanbanGroup,
|
||||
propertyData: {
|
||||
default: () => config.propertyData.default(),
|
||||
},
|
||||
jsonValue: {
|
||||
type: ({ data, dataSource }) =>
|
||||
config.jsonValue.type({
|
||||
data: data as TData,
|
||||
dataSource,
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const immutableBooleanMeta = toTestMeta('immutable-boolean', {
|
||||
...checkboxPropertyModelConfig.config,
|
||||
kanbanGroup: {
|
||||
enabled: true,
|
||||
mutable: false,
|
||||
},
|
||||
});
|
||||
|
||||
const createMockDataSource = (columns: Column[]): MockDataSource => {
|
||||
const properties$ = signal(columns.map(column => column.id));
|
||||
const typeById = new Map(columns.map(column => [column.id, column.type]));
|
||||
const dataById = new Map(
|
||||
columns.map(column => [column.id, column.data ?? {}])
|
||||
);
|
||||
const services = new Map<unknown, unknown>();
|
||||
|
||||
const metaEntries: Array<[string, TestPropertyMeta]> = [
|
||||
[
|
||||
checkboxPropertyModelConfig.type,
|
||||
toTestMeta(
|
||||
checkboxPropertyModelConfig.type,
|
||||
checkboxPropertyModelConfig.config
|
||||
),
|
||||
],
|
||||
[
|
||||
selectPropertyModelConfig.type,
|
||||
toTestMeta(
|
||||
selectPropertyModelConfig.type,
|
||||
selectPropertyModelConfig.config
|
||||
),
|
||||
],
|
||||
[
|
||||
multiSelectPropertyModelConfig.type,
|
||||
toTestMeta(
|
||||
multiSelectPropertyModelConfig.type,
|
||||
multiSelectPropertyModelConfig.config
|
||||
),
|
||||
],
|
||||
[
|
||||
textPropertyModelConfig.type,
|
||||
toTestMeta(textPropertyModelConfig.type, textPropertyModelConfig.config),
|
||||
],
|
||||
[immutableBooleanMeta.type, immutableBooleanMeta],
|
||||
];
|
||||
const metaByType = new Map(metaEntries);
|
||||
|
||||
const asRecord = (value: unknown): Record<string, unknown> =>
|
||||
typeof value === 'object' && value != null
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
let autoColumnId = 0;
|
||||
|
||||
const dataSource = {
|
||||
properties$,
|
||||
provider: {
|
||||
getAll: () => new Map<unknown, unknown>(),
|
||||
},
|
||||
serviceGetOrCreate: (key: unknown, create: () => unknown) => {
|
||||
if (!services.has(key)) {
|
||||
services.set(key, create());
|
||||
}
|
||||
return services.get(key);
|
||||
},
|
||||
propertyTypeGet: (propertyId: string) => typeById.get(propertyId),
|
||||
propertyMetaGet: (type: string) => metaByType.get(type),
|
||||
propertyDataGet: (propertyId: string) => asRecord(dataById.get(propertyId)),
|
||||
propertyDataTypeGet: (propertyId: string) => {
|
||||
const type = typeById.get(propertyId);
|
||||
if (!type) {
|
||||
return;
|
||||
}
|
||||
const meta = metaByType.get(type);
|
||||
if (!meta) {
|
||||
return;
|
||||
}
|
||||
return meta.config.jsonValue.type({
|
||||
data: asRecord(dataById.get(propertyId)),
|
||||
dataSource: asDataSource(dataSource),
|
||||
});
|
||||
},
|
||||
propertyAdd: (
|
||||
_position: unknown,
|
||||
ops?: {
|
||||
type?: string;
|
||||
}
|
||||
) => {
|
||||
const type = ops?.type ?? selectPropertyModelConfig.type;
|
||||
const id = `auto-${++autoColumnId}`;
|
||||
const meta = metaByType.get(type);
|
||||
const data = meta?.config.propertyData.default() ?? {};
|
||||
|
||||
typeById.set(id, type);
|
||||
dataById.set(id, data);
|
||||
properties$.value = [...properties$.value, id];
|
||||
return id;
|
||||
},
|
||||
propertyDataSet: (propertyId: string, data: Record<string, unknown>) => {
|
||||
dataById.set(propertyId, data);
|
||||
},
|
||||
};
|
||||
|
||||
return dataSource;
|
||||
};
|
||||
|
||||
const createDragController = () => {
|
||||
type DragLogic = ConstructorParameters<typeof KanbanDragController>[0];
|
||||
return new KanbanDragController({} as DragLogic);
|
||||
};
|
||||
|
||||
describe('kanban', () => {
|
||||
describe('group-by define', () => {
|
||||
it('boolean group should not include ungroup bucket', () => {
|
||||
const booleanGroup = groupByMatchers.find(
|
||||
group => group.name === 'boolean'
|
||||
);
|
||||
expect(booleanGroup).toBeDefined();
|
||||
|
||||
const keys = booleanGroup!
|
||||
.defaultKeys(t.boolean.instance())
|
||||
.map(group => group.key);
|
||||
|
||||
expect(keys).toEqual(['true', 'false']);
|
||||
});
|
||||
|
||||
it('boolean group should fallback invalid values to false bucket', () => {
|
||||
const booleanGroup = groupByMatchers.find(
|
||||
group => group.name === 'boolean'
|
||||
);
|
||||
expect(booleanGroup).toBeDefined();
|
||||
|
||||
const groups = booleanGroup!.valuesGroup(undefined, t.boolean.instance());
|
||||
expect(groups).toEqual([{ key: 'false', value: false }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('columns materialization', () => {
|
||||
it('appends missing properties while preserving existing order and state', () => {
|
||||
const columns = [{ id: 'status', hide: true }, { id: 'title' }];
|
||||
|
||||
const next = materializeKanbanColumns(columns, [
|
||||
'title',
|
||||
'status',
|
||||
'date',
|
||||
]);
|
||||
|
||||
expect(next).toEqual([
|
||||
{ id: 'status', hide: true },
|
||||
{ id: 'title' },
|
||||
{ id: 'date' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops stale columns that no longer exist in data source', () => {
|
||||
const columns = [{ id: 'title' }, { id: 'removed', hide: true }];
|
||||
|
||||
const next = materializeKanbanColumns(columns, ['title']);
|
||||
|
||||
expect(next).toEqual([{ id: 'title' }]);
|
||||
});
|
||||
|
||||
it('returns original reference when columns are already materialized', () => {
|
||||
const columns = [{ id: 'title' }, { id: 'status', hide: true }];
|
||||
|
||||
const next = materializeKanbanColumns(columns, ['title', 'status']);
|
||||
|
||||
expect(next).toBe(columns);
|
||||
});
|
||||
});
|
||||
|
||||
describe('drag indicator', () => {
|
||||
it('shows drop preview when insert position exists', () => {
|
||||
const controller = createDragController();
|
||||
const position = {
|
||||
group: {} as KanbanGroup,
|
||||
position: 'end' as const,
|
||||
};
|
||||
controller.getInsertPosition = vi.fn().mockReturnValue(position);
|
||||
|
||||
const displaySpy = vi.spyOn(controller.dropPreview, 'display');
|
||||
const removeSpy = vi.spyOn(controller.dropPreview, 'remove');
|
||||
|
||||
const result = controller.showIndicator({} as MouseEvent, undefined);
|
||||
|
||||
expect(result).toBe(position);
|
||||
expect(displaySpy).toHaveBeenCalledWith(
|
||||
position.group,
|
||||
undefined,
|
||||
undefined
|
||||
);
|
||||
expect(removeSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('removes drop preview when insert position does not exist', () => {
|
||||
const controller = createDragController();
|
||||
controller.getInsertPosition = vi.fn().mockReturnValue(undefined);
|
||||
|
||||
const displaySpy = vi.spyOn(controller.dropPreview, 'display');
|
||||
const removeSpy = vi.spyOn(controller.dropPreview, 'remove');
|
||||
|
||||
const result = controller.showIndicator({} as MouseEvent, undefined);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(displaySpy).not.toHaveBeenCalled();
|
||||
expect(removeSpy).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('forwards hovered card to drop preview for precise insertion cursor', () => {
|
||||
const controller = createDragController();
|
||||
const hoveredCard = document.createElement(
|
||||
'affine-data-view-kanban-card'
|
||||
) as KanbanCard;
|
||||
const positionCard = document.createElement(
|
||||
'affine-data-view-kanban-card'
|
||||
) as KanbanCard;
|
||||
const position = {
|
||||
group: {} as KanbanGroup,
|
||||
card: positionCard,
|
||||
position: { before: true, id: 'card-id' } as const,
|
||||
};
|
||||
controller.getInsertPosition = vi.fn().mockReturnValue(position);
|
||||
|
||||
const displaySpy = vi.spyOn(controller.dropPreview, 'display');
|
||||
|
||||
controller.showIndicator({} as MouseEvent, hoveredCard);
|
||||
|
||||
expect(displaySpy).toHaveBeenCalledWith(
|
||||
position.group,
|
||||
hoveredCard,
|
||||
position.card
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('group-by utils', () => {
|
||||
it('allows only kanban-enabled property types to group', () => {
|
||||
const dataSource = createMockDataSource([
|
||||
{ id: 'text', type: textPropertyModelConfig.type },
|
||||
{ id: 'select', type: selectPropertyModelConfig.type },
|
||||
{ id: 'multi-select', type: multiSelectPropertyModelConfig.type },
|
||||
{ id: 'checkbox', type: checkboxPropertyModelConfig.type },
|
||||
]);
|
||||
|
||||
expect(canGroupable(asDataSource(dataSource), 'text')).toBe(false);
|
||||
expect(canGroupable(asDataSource(dataSource), 'select')).toBe(true);
|
||||
expect(canGroupable(asDataSource(dataSource), 'multi-select')).toBe(true);
|
||||
expect(canGroupable(asDataSource(dataSource), 'checkbox')).toBe(true);
|
||||
});
|
||||
|
||||
it('prefers mutable group column over immutable ones', () => {
|
||||
const dataSource = createMockDataSource([
|
||||
{
|
||||
id: 'immutable-bool',
|
||||
type: 'immutable-boolean',
|
||||
},
|
||||
{
|
||||
id: 'checkbox',
|
||||
type: checkboxPropertyModelConfig.type,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(pickKanbanGroupColumn(asDataSource(dataSource))).toBe('checkbox');
|
||||
});
|
||||
|
||||
it('creates default status select column when no groupable column exists', () => {
|
||||
const dataSource = createMockDataSource([
|
||||
{
|
||||
id: 'text',
|
||||
type: textPropertyModelConfig.type,
|
||||
},
|
||||
]);
|
||||
|
||||
const statusColumnId = ensureKanbanGroupColumn(asDataSource(dataSource));
|
||||
|
||||
expect(statusColumnId).toBeTruthy();
|
||||
expect(dataSource.propertyTypeGet(statusColumnId!)).toBe(
|
||||
selectPropertyModelConfig.type
|
||||
);
|
||||
const options =
|
||||
(
|
||||
dataSource.propertyDataGet(statusColumnId!) as {
|
||||
options?: { value: string }[];
|
||||
}
|
||||
).options ?? [];
|
||||
expect(options.map(option => option.value)).toEqual([
|
||||
'Todo',
|
||||
'In Progress',
|
||||
'Done',
|
||||
]);
|
||||
});
|
||||
|
||||
it('defaults hideEmpty to true for non-option groups', () => {
|
||||
const dataSource = createMockDataSource([
|
||||
{
|
||||
id: 'checkbox',
|
||||
type: checkboxPropertyModelConfig.type,
|
||||
},
|
||||
]);
|
||||
|
||||
const next = resolveKanbanGroupBy(asDataSource(dataSource));
|
||||
expect(next?.columnId).toBe('checkbox');
|
||||
expect(next?.hideEmpty).toBe(true);
|
||||
expect(next?.name).toBe('boolean');
|
||||
});
|
||||
|
||||
it('defaults hideEmpty to false for select grouping', () => {
|
||||
const dataSource = createMockDataSource([
|
||||
{
|
||||
id: 'select',
|
||||
type: selectPropertyModelConfig.type,
|
||||
},
|
||||
]);
|
||||
|
||||
const next = resolveKanbanGroupBy(asDataSource(dataSource));
|
||||
expect(next?.columnId).toBe('select');
|
||||
expect(next?.hideEmpty).toBe(false);
|
||||
expect(next?.name).toBe('select');
|
||||
});
|
||||
|
||||
it('preserves sort and explicit hideEmpty when resolving groupBy', () => {
|
||||
const dataSource = createMockDataSource([
|
||||
{
|
||||
id: 'checkbox',
|
||||
type: checkboxPropertyModelConfig.type,
|
||||
},
|
||||
]);
|
||||
const current: GroupBy = {
|
||||
type: 'groupBy',
|
||||
columnId: 'checkbox',
|
||||
name: 'boolean',
|
||||
sort: { desc: true },
|
||||
hideEmpty: true,
|
||||
};
|
||||
|
||||
const next = resolveKanbanGroupBy(asDataSource(dataSource), current);
|
||||
|
||||
expect(next?.columnId).toBe('checkbox');
|
||||
expect(next?.sort).toEqual({ desc: true });
|
||||
expect(next?.hideEmpty).toBe(true);
|
||||
});
|
||||
|
||||
it('replaces current non-groupable column with a valid kanban column', () => {
|
||||
const dataSource = createMockDataSource([
|
||||
{ id: 'text', type: textPropertyModelConfig.type },
|
||||
{ id: 'checkbox', type: checkboxPropertyModelConfig.type },
|
||||
]);
|
||||
|
||||
const next = resolveKanbanGroupBy(asDataSource(dataSource), {
|
||||
type: 'groupBy',
|
||||
columnId: 'text',
|
||||
name: 'text',
|
||||
});
|
||||
|
||||
expect(next?.columnId).toBe('checkbox');
|
||||
expect(next?.name).toBe('boolean');
|
||||
expect(next?.hideEmpty).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detail selection', () => {
|
||||
it('should avoid recursive selection update when exiting select edit mode', () => {
|
||||
vi.stubGlobal('requestAnimationFrame', ((cb: FrameRequestCallback) => {
|
||||
cb(0);
|
||||
return 0;
|
||||
}) as typeof requestAnimationFrame);
|
||||
try {
|
||||
let selection: DetailSelection;
|
||||
let beforeExitCalls = 0;
|
||||
|
||||
const cell = {
|
||||
beforeEnterEditMode: () => true,
|
||||
beforeExitEditingMode: () => {
|
||||
beforeExitCalls += 1;
|
||||
selection.selection = {
|
||||
propertyId: 'status',
|
||||
isEditing: false,
|
||||
};
|
||||
},
|
||||
afterEnterEditingMode: () => {},
|
||||
focusCell: () => true,
|
||||
blurCell: () => true,
|
||||
forceUpdate: () => {},
|
||||
} satisfies DataViewCellLifeCycle;
|
||||
|
||||
const field = {
|
||||
isFocus$: signal(false),
|
||||
isEditing$: signal(false),
|
||||
cell,
|
||||
focus: () => {},
|
||||
blur: () => {},
|
||||
};
|
||||
|
||||
const detail = {
|
||||
querySelector: () => field,
|
||||
};
|
||||
|
||||
selection = new DetailSelection(detail);
|
||||
selection.selection = {
|
||||
propertyId: 'status',
|
||||
isEditing: true,
|
||||
};
|
||||
|
||||
selection.selection = {
|
||||
propertyId: 'status',
|
||||
isEditing: false,
|
||||
};
|
||||
|
||||
expect(beforeExitCalls).toBe(1);
|
||||
expect(field.isEditing$.value).toBe(false);
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,36 +0,0 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import { mobileEffects } from '../view-presets/table/mobile/effect.js';
|
||||
import type { MobileTableGroup } from '../view-presets/table/mobile/group.js';
|
||||
import { pcEffects } from '../view-presets/table/pc/effect.js';
|
||||
import type { TableGroup } from '../view-presets/table/pc/group.js';
|
||||
|
||||
/** @vitest-environment happy-dom */
|
||||
|
||||
describe('TableGroup', () => {
|
||||
test('toggle collapse on pc', () => {
|
||||
pcEffects();
|
||||
const group = document.createElement(
|
||||
'affine-data-view-table-group'
|
||||
) as TableGroup;
|
||||
|
||||
expect(group.collapsed$.value).toBe(false);
|
||||
(group as any)._toggleCollapse();
|
||||
expect(group.collapsed$.value).toBe(true);
|
||||
(group as any)._toggleCollapse();
|
||||
expect(group.collapsed$.value).toBe(false);
|
||||
});
|
||||
|
||||
test('toggle collapse on mobile', () => {
|
||||
mobileEffects();
|
||||
const group = document.createElement(
|
||||
'mobile-table-group'
|
||||
) as MobileTableGroup;
|
||||
|
||||
expect(group.collapsed$.value).toBe(false);
|
||||
(group as any)._toggleCollapse();
|
||||
expect(group.collapsed$.value).toBe(true);
|
||||
(group as any)._toggleCollapse();
|
||||
expect(group.collapsed$.value).toBe(false);
|
||||
});
|
||||
});
|
||||
101
blocksuite/affine/data-view/src/__tests__/table.unit.spec.ts
Normal file
101
blocksuite/affine/data-view/src/__tests__/table.unit.spec.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import { numberFormats } from '../property-presets/number/utils/formats.js';
|
||||
import {
|
||||
formatNumber,
|
||||
NumberFormatSchema,
|
||||
parseNumber,
|
||||
} from '../property-presets/number/utils/formatter.js';
|
||||
import { mobileEffects } from '../view-presets/table/mobile/effect.js';
|
||||
import type { MobileTableGroup } from '../view-presets/table/mobile/group.js';
|
||||
import { pcEffects } from '../view-presets/table/pc/effect.js';
|
||||
import type { TableGroup } from '../view-presets/table/pc/group.js';
|
||||
|
||||
/** @vitest-environment happy-dom */
|
||||
|
||||
describe('TableGroup', () => {
|
||||
test('toggle collapse on pc', () => {
|
||||
pcEffects();
|
||||
const group = document.createElement(
|
||||
'affine-data-view-table-group'
|
||||
) as TableGroup;
|
||||
|
||||
expect(group.collapsed$.value).toBe(false);
|
||||
(group as any)._toggleCollapse();
|
||||
expect(group.collapsed$.value).toBe(true);
|
||||
(group as any)._toggleCollapse();
|
||||
expect(group.collapsed$.value).toBe(false);
|
||||
});
|
||||
|
||||
test('toggle collapse on mobile', () => {
|
||||
mobileEffects();
|
||||
const group = document.createElement(
|
||||
'mobile-table-group'
|
||||
) as MobileTableGroup;
|
||||
|
||||
expect(group.collapsed$.value).toBe(false);
|
||||
(group as any)._toggleCollapse();
|
||||
expect(group.collapsed$.value).toBe(true);
|
||||
(group as any)._toggleCollapse();
|
||||
expect(group.collapsed$.value).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('number formatter', () => {
|
||||
test('number format menu should expose all schema formats', () => {
|
||||
const menuFormats = numberFormats.map(format => format.type);
|
||||
const schemaFormats = NumberFormatSchema.options;
|
||||
|
||||
expect(new Set(menuFormats)).toEqual(new Set(schemaFormats));
|
||||
expect(menuFormats).toHaveLength(schemaFormats.length);
|
||||
});
|
||||
|
||||
test('formats grouped decimal numbers with Intl grouping rules', () => {
|
||||
const value = 11451.4;
|
||||
const decimals = 1;
|
||||
const expected = new Intl.NumberFormat(navigator.language, {
|
||||
style: 'decimal',
|
||||
useGrouping: true,
|
||||
minimumFractionDigits: decimals,
|
||||
maximumFractionDigits: decimals,
|
||||
}).format(value);
|
||||
|
||||
expect(formatNumber(value, 'numberWithCommas', decimals)).toBe(expected);
|
||||
});
|
||||
|
||||
test('formats percent values with Intl percent rules', () => {
|
||||
const value = 0.1234;
|
||||
const decimals = 2;
|
||||
const expected = new Intl.NumberFormat(navigator.language, {
|
||||
style: 'percent',
|
||||
useGrouping: false,
|
||||
minimumFractionDigits: decimals,
|
||||
maximumFractionDigits: decimals,
|
||||
}).format(value);
|
||||
|
||||
expect(formatNumber(value, 'percent', decimals)).toBe(expected);
|
||||
});
|
||||
|
||||
test('formats currency values with Intl currency rules', () => {
|
||||
const value = 11451.4;
|
||||
const expected = new Intl.NumberFormat(navigator.language, {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
currencyDisplay: 'symbol',
|
||||
}).format(value);
|
||||
|
||||
expect(formatNumber(value, 'currencyUSD')).toBe(expected);
|
||||
});
|
||||
|
||||
test('parses grouped number string pasted from clipboard', () => {
|
||||
expect(parseNumber('11,451.4')).toBe(11451.4);
|
||||
});
|
||||
|
||||
test('keeps regular decimal parsing', () => {
|
||||
expect(parseNumber('123.45')).toBe(123.45);
|
||||
});
|
||||
|
||||
test('supports comma as decimal separator in locale-specific input', () => {
|
||||
expect(parseNumber('11451,4', ',')).toBe(11451.4);
|
||||
});
|
||||
});
|
||||
@@ -22,7 +22,6 @@ import { html } from 'lit/static-html.js';
|
||||
import { dataViewCommonStyle } from './common/css-variable.js';
|
||||
import type { DataSource } from './data-source/index.js';
|
||||
import type { DataViewSelection } from './types.js';
|
||||
import { cacheComputed } from './utils/cache.js';
|
||||
import { renderUniLit } from './utils/uni-component/index.js';
|
||||
import type { DataViewUILogicBase } from './view/data-view-base.js';
|
||||
import type { SingleView } from './view-manager/single-view.js';
|
||||
@@ -75,12 +74,38 @@ export class DataViewRootUILogic {
|
||||
|
||||
return new (logic(view))(this, view);
|
||||
}
|
||||
private readonly views$ = cacheComputed(this.viewManager.views$, viewId =>
|
||||
this.createDataViewUILogic(viewId)
|
||||
);
|
||||
private readonly _viewsCache = new Map<
|
||||
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(() => {
|
||||
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>();
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { KanbanCardSelection } from '../../view-presets';
|
||||
import type { KanbanCard } from '../../view-presets/kanban/pc/card.js';
|
||||
import { KanbanCell } from '../../view-presets/kanban/pc/cell.js';
|
||||
import type { RecordDetail } from './detail.js';
|
||||
import { RecordField } from './field.js';
|
||||
|
||||
type DetailViewSelection = {
|
||||
@@ -9,16 +8,39 @@ type DetailViewSelection = {
|
||||
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 {
|
||||
_selection?: DetailViewSelection;
|
||||
|
||||
onSelect = (selection?: DetailViewSelection) => {
|
||||
if (isSameDetailSelection(this._selection, selection)) {
|
||||
return;
|
||||
}
|
||||
const old = this._selection;
|
||||
this._selection = selection;
|
||||
if (old) {
|
||||
this.blur(old);
|
||||
}
|
||||
this._selection = selection;
|
||||
if (selection) {
|
||||
if (selection && isSameDetailSelection(this._selection, selection)) {
|
||||
this.focus(selection);
|
||||
}
|
||||
};
|
||||
@@ -49,7 +71,7 @@ export class DetailSelection {
|
||||
}
|
||||
}
|
||||
|
||||
constructor(private readonly viewEle: RecordDetail) {}
|
||||
constructor(private readonly viewEle: DetailSelectionHost) {}
|
||||
|
||||
blur(selection: DetailViewSelection) {
|
||||
const container = this.getFocusCellContainer(selection);
|
||||
@@ -111,8 +133,10 @@ export class DetailSelection {
|
||||
}
|
||||
|
||||
focusFirstCell() {
|
||||
const firstId = this.viewEle.querySelector('affine-data-view-record-field')
|
||||
?.column.id;
|
||||
const firstField = this.viewEle.querySelector(
|
||||
'affine-data-view-record-field'
|
||||
) as RecordField | undefined;
|
||||
const firstId = firstField?.column.id;
|
||||
if (firstId) {
|
||||
this.selection = {
|
||||
propertyId: firstId,
|
||||
@@ -144,11 +168,12 @@ export class DetailSelection {
|
||||
|
||||
getSelectCard(selection: KanbanCardSelection) {
|
||||
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
|
||||
.querySelector(`affine-data-view-kanban-group[data-key="${groupKey}"]`)
|
||||
?.querySelector(
|
||||
`affine-data-view-kanban-card[data-card-id="${cardId}"]`
|
||||
) as KanbanCard | undefined;
|
||||
return group?.querySelector(
|
||||
`affine-data-view-kanban-card[data-card-id="${cardId}"]`
|
||||
) as KanbanCard | undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,12 +247,13 @@ export const groupByMatchers: GroupByConfig[] = [
|
||||
matchType: t.boolean.instance(),
|
||||
groupName: (_t, v) => `${v?.toString() ?? ''}`,
|
||||
defaultKeys: _t => [
|
||||
ungroups,
|
||||
{ key: 'true', value: true },
|
||||
{ key: 'false', value: false },
|
||||
],
|
||||
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,
|
||||
view: createUniComponentFromWebComponent(BooleanGroupView),
|
||||
}),
|
||||
|
||||
@@ -17,6 +17,7 @@ import { css, html, unsafeCSS } from 'lit';
|
||||
import { property, query } from 'lit/decorators.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 { TableSingleView } from '../../view-presets/table/table-view-manager.js';
|
||||
import { dataViewCssVariable } from '../common/css-variable.js';
|
||||
@@ -278,6 +279,9 @@ export const selectGroupByProperty = (
|
||||
if (property.type$.value === 'title') {
|
||||
return false;
|
||||
}
|
||||
if (view instanceof KanbanSingleView) {
|
||||
return canGroupable(view.manager.dataSource, property.id);
|
||||
}
|
||||
const dataType = property.dataType$.value;
|
||||
if (!dataType) {
|
||||
return false;
|
||||
|
||||
@@ -16,6 +16,10 @@ export type GetJsonValueFromConfig<T> =
|
||||
export type PropertyConfig<Data, RawValue = unknown, JsonValue = unknown> = {
|
||||
name: string;
|
||||
hide?: boolean;
|
||||
kanbanGroup?: {
|
||||
enabled: boolean;
|
||||
mutable?: boolean;
|
||||
};
|
||||
propertyData: {
|
||||
schema: ZodType<Data>;
|
||||
default: () => Data;
|
||||
|
||||
@@ -12,6 +12,5 @@ export type PropertyDataUpdater<
|
||||
> = (data: Data) => Partial<Data>;
|
||||
|
||||
export interface DatabaseFlags {
|
||||
enable_number_formatting: boolean;
|
||||
enable_table_virtual_scroll: boolean;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,10 @@ const FALSE_VALUES = new Set([
|
||||
|
||||
export const checkboxPropertyModelConfig = checkboxPropertyType.modelConfig({
|
||||
name: 'Checkbox',
|
||||
kanbanGroup: {
|
||||
enabled: true,
|
||||
mutable: true,
|
||||
},
|
||||
propertyData: {
|
||||
schema: zod.object({}),
|
||||
default: () => ({}),
|
||||
|
||||
@@ -10,6 +10,10 @@ export const multiSelectPropertyType = propertyType('multi-select');
|
||||
export const multiSelectPropertyModelConfig =
|
||||
multiSelectPropertyType.modelConfig({
|
||||
name: 'Multi-select',
|
||||
kanbanGroup: {
|
||||
enabled: true,
|
||||
mutable: true,
|
||||
},
|
||||
propertyData: {
|
||||
schema: SelectPropertySchema,
|
||||
default: () => ({
|
||||
|
||||
@@ -24,17 +24,11 @@ export class NumberCell extends BaseCellRenderer<
|
||||
private accessor _inputEle!: HTMLInputElement;
|
||||
|
||||
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 formatMode = (this.property.data$.value.format ??
|
||||
'number') as NumberFormat;
|
||||
|
||||
return value != undefined
|
||||
? enableNewFormatting
|
||||
? formatNumber(value, formatMode, decimals)
|
||||
: value.toString()
|
||||
: '';
|
||||
return value != undefined ? formatNumber(value, formatMode, decimals) : '';
|
||||
}
|
||||
|
||||
private readonly _keydown = (e: KeyboardEvent) => {
|
||||
@@ -58,9 +52,7 @@ export class NumberCell extends BaseCellRenderer<
|
||||
return;
|
||||
}
|
||||
|
||||
const enableNewFormatting =
|
||||
this.view.featureFlags$.value.enable_number_formatting;
|
||||
const value = enableNewFormatting ? parseNumber(str) : parseFloat(str);
|
||||
const value = parseNumber(str);
|
||||
if (isNaN(value)) {
|
||||
if (this._inputEle) {
|
||||
this._inputEle.value = this.value
|
||||
|
||||
@@ -3,6 +3,7 @@ import zod from 'zod';
|
||||
import { t } from '../../core/logical/type-presets.js';
|
||||
import { propertyType } from '../../core/property/property-config.js';
|
||||
import { NumberPropertySchema } from './types.js';
|
||||
import { parseNumber } from './utils/formatter.js';
|
||||
export const numberPropertyType = propertyType('number');
|
||||
|
||||
export const numberPropertyModelConfig = numberPropertyType.modelConfig({
|
||||
@@ -21,7 +22,7 @@ export const numberPropertyModelConfig = numberPropertyType.modelConfig({
|
||||
default: () => null,
|
||||
toString: ({ value }) => value?.toString() ?? '',
|
||||
fromString: ({ value }) => {
|
||||
const num = value ? Number(value) : NaN;
|
||||
const num = value ? parseNumber(value) : NaN;
|
||||
return { value: isNaN(num) ? null : num };
|
||||
},
|
||||
toJson: ({ value }) => value ?? null,
|
||||
|
||||
@@ -11,6 +11,10 @@ export const SelectPropertySchema = zod.object({
|
||||
export type SelectPropertyData = zod.infer<typeof SelectPropertySchema>;
|
||||
export const selectPropertyModelConfig = selectPropertyType.modelConfig({
|
||||
name: 'Select',
|
||||
kanbanGroup: {
|
||||
enabled: true,
|
||||
mutable: true,
|
||||
},
|
||||
propertyData: {
|
||||
schema: SelectPropertySchema,
|
||||
default: () => ({
|
||||
|
||||
@@ -3,17 +3,9 @@ import { kanbanViewModel } from './kanban/index.js';
|
||||
import { tableViewModel } from './table/index.js';
|
||||
|
||||
export const viewConverts = [
|
||||
createViewConvert(tableViewModel, kanbanViewModel, data => {
|
||||
if (data.groupBy) {
|
||||
return {
|
||||
filter: data.filter,
|
||||
groupBy: data.groupBy,
|
||||
};
|
||||
}
|
||||
return {
|
||||
filter: data.filter,
|
||||
};
|
||||
}),
|
||||
createViewConvert(tableViewModel, kanbanViewModel, data => ({
|
||||
filter: data.filter,
|
||||
})),
|
||||
createViewConvert(kanbanViewModel, tableViewModel, data => ({
|
||||
filter: data.filter,
|
||||
groupBy: data.groupBy,
|
||||
|
||||
@@ -2,9 +2,9 @@ import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||
|
||||
import type { GroupBy, GroupProperty } from '../../core/common/types.js';
|
||||
import type { FilterGroup } from '../../core/filter/types.js';
|
||||
import { defaultGroupBy, getGroupByService, t } from '../../core/index.js';
|
||||
import type { Sort } from '../../core/sort/types.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';
|
||||
|
||||
export const kanbanViewType = viewType('kanban');
|
||||
@@ -34,41 +34,16 @@ export const kanbanViewModel = kanbanViewType.createModel<KanbanViewData>({
|
||||
defaultName: 'Kanban View',
|
||||
dataViewManager: KanbanSingleView,
|
||||
defaultData: viewManager => {
|
||||
const groupByService = getGroupByService(viewManager.dataSource);
|
||||
const columns = viewManager.dataSource.properties$.value;
|
||||
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) {
|
||||
const groupBy = resolveKanbanGroupBy(viewManager.dataSource);
|
||||
if (!groupBy) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.DatabaseBlockError,
|
||||
'no groupable column found'
|
||||
);
|
||||
}
|
||||
const type = viewManager.dataSource.propertyTypeGet(columnId);
|
||||
const meta = type && viewManager.dataSource.propertyMetaGet(type);
|
||||
const data = viewManager.dataSource.propertyDataGet(columnId);
|
||||
if (!columnId || !meta || !data) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.DatabaseBlockError,
|
||||
'not implement yet'
|
||||
);
|
||||
}
|
||||
|
||||
const columns = viewManager.dataSource.properties$.value;
|
||||
|
||||
return {
|
||||
columns: columns.map(id => ({
|
||||
id: id,
|
||||
@@ -78,7 +53,7 @@ export const kanbanViewModel = kanbanViewType.createModel<KanbanViewData>({
|
||||
op: 'and',
|
||||
conditions: [],
|
||||
},
|
||||
groupBy: defaultGroupBy(viewManager.dataSource, meta, columnId, data),
|
||||
groupBy,
|
||||
header: {
|
||||
titleColumn: viewManager.dataSource.properties$.value.find(
|
||||
id => viewManager.dataSource.propertyTypeGet(id) === 'title'
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { nanoid } from '@blocksuite/store';
|
||||
|
||||
import type { GroupBy } from '../../core/common/types.js';
|
||||
import { getTagColor } from '../../core/component/tags/colors.js';
|
||||
import type { DataSource } from '../../core/data-source/base.js';
|
||||
import { defaultGroupBy } from '../../core/group-by/default.js';
|
||||
import { getGroupByService } from '../../core/group-by/matcher.js';
|
||||
|
||||
type KanbanGroupCapability = 'mutable' | 'immutable' | 'none';
|
||||
|
||||
const KANBAN_DEFAULT_STATUS_OPTIONS = ['Todo', 'In Progress', 'Done'];
|
||||
const SHOW_EMPTY_GROUPS_BY_DEFAULT = new Set(['select', 'multi-select']);
|
||||
|
||||
export const getKanbanDefaultHideEmpty = (groupName?: string): boolean => {
|
||||
return !groupName || !SHOW_EMPTY_GROUPS_BY_DEFAULT.has(groupName);
|
||||
};
|
||||
|
||||
const getKanbanGroupCapability = (
|
||||
dataSource: DataSource,
|
||||
propertyId: string
|
||||
): KanbanGroupCapability => {
|
||||
const type = dataSource.propertyTypeGet(propertyId);
|
||||
if (!type) {
|
||||
return 'none';
|
||||
}
|
||||
|
||||
const meta = dataSource.propertyMetaGet(type);
|
||||
const kanbanGroup = meta?.config.kanbanGroup;
|
||||
if (!kanbanGroup?.enabled) {
|
||||
return 'none';
|
||||
}
|
||||
return kanbanGroup.mutable ? 'mutable' : 'immutable';
|
||||
};
|
||||
|
||||
const hasMatchingGroupBy = (dataSource: DataSource, propertyId: string) => {
|
||||
const dataType = dataSource.propertyDataTypeGet(propertyId);
|
||||
if (!dataType) {
|
||||
return false;
|
||||
}
|
||||
const groupByService = getGroupByService(dataSource);
|
||||
return !!groupByService?.matcher.match(dataType);
|
||||
};
|
||||
|
||||
const createGroupByFromColumn = (
|
||||
dataSource: DataSource,
|
||||
columnId: string
|
||||
): GroupBy | undefined => {
|
||||
const type = dataSource.propertyTypeGet(columnId);
|
||||
if (!type) {
|
||||
return;
|
||||
}
|
||||
const meta = dataSource.propertyMetaGet(type);
|
||||
if (!meta) {
|
||||
return;
|
||||
}
|
||||
return defaultGroupBy(
|
||||
dataSource,
|
||||
meta,
|
||||
columnId,
|
||||
dataSource.propertyDataGet(columnId)
|
||||
);
|
||||
};
|
||||
|
||||
export const canGroupable = (dataSource: DataSource, propertyId: string) => {
|
||||
return (
|
||||
getKanbanGroupCapability(dataSource, propertyId) !== 'none' &&
|
||||
hasMatchingGroupBy(dataSource, propertyId)
|
||||
);
|
||||
};
|
||||
|
||||
export const pickKanbanGroupColumn = (
|
||||
dataSource: DataSource,
|
||||
propertyIds: string[] = dataSource.properties$.value
|
||||
): string | undefined => {
|
||||
let immutableFallback: string | undefined;
|
||||
|
||||
for (const propertyId of propertyIds) {
|
||||
const capability = getKanbanGroupCapability(dataSource, propertyId);
|
||||
if (capability === 'none' || !hasMatchingGroupBy(dataSource, propertyId)) {
|
||||
continue;
|
||||
}
|
||||
if (capability === 'mutable') {
|
||||
return propertyId;
|
||||
}
|
||||
immutableFallback ??= propertyId;
|
||||
}
|
||||
|
||||
return immutableFallback;
|
||||
};
|
||||
|
||||
export const ensureKanbanGroupColumn = (
|
||||
dataSource: DataSource
|
||||
): string | undefined => {
|
||||
const columnId = pickKanbanGroupColumn(dataSource);
|
||||
if (columnId) {
|
||||
return columnId;
|
||||
}
|
||||
|
||||
const statusId = dataSource.propertyAdd('end', {
|
||||
type: 'select',
|
||||
name: 'Status',
|
||||
});
|
||||
if (!statusId) {
|
||||
return;
|
||||
}
|
||||
|
||||
dataSource.propertyDataSet(statusId, {
|
||||
options: KANBAN_DEFAULT_STATUS_OPTIONS.map(value => ({
|
||||
id: nanoid(),
|
||||
value,
|
||||
color: getTagColor(),
|
||||
})),
|
||||
});
|
||||
|
||||
return statusId;
|
||||
};
|
||||
|
||||
export const resolveKanbanGroupBy = (
|
||||
dataSource: DataSource,
|
||||
current?: GroupBy
|
||||
): GroupBy | undefined => {
|
||||
const keepColumnId =
|
||||
current?.columnId && canGroupable(dataSource, current.columnId)
|
||||
? current.columnId
|
||||
: undefined;
|
||||
|
||||
const columnId = keepColumnId ?? ensureKanbanGroupColumn(dataSource);
|
||||
if (!columnId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const next = createGroupByFromColumn(dataSource, columnId);
|
||||
if (!next) {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
...next,
|
||||
sort: current?.sort,
|
||||
hideEmpty: current?.hideEmpty ?? getKanbanDefaultHideEmpty(next.name),
|
||||
};
|
||||
};
|
||||
@@ -17,7 +17,52 @@ import {
|
||||
import { fromJson } from '../../core/property/utils';
|
||||
import { PropertyBase } from '../../core/view-manager/property.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> {
|
||||
propertiesRaw$ = computed(() => {
|
||||
@@ -61,16 +106,27 @@ export class KanbanSingleView extends SingleViewBase<KanbanViewData> {
|
||||
);
|
||||
|
||||
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(
|
||||
groupTraitKey,
|
||||
new GroupTrait(this.groupBy$, this, {
|
||||
groupBySet: groupBy => {
|
||||
const nextGroupBy = resolveKanbanGroupBy(
|
||||
this.manager.dataSource,
|
||||
groupBy
|
||||
);
|
||||
this.dataUpdate(() => {
|
||||
return {
|
||||
groupBy: groupBy,
|
||||
groupBy: nextGroupBy,
|
||||
};
|
||||
});
|
||||
},
|
||||
@@ -200,6 +256,23 @@ export class KanbanSingleView extends SingleViewBase<KanbanViewData> {
|
||||
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() {
|
||||
return this.data$.value;
|
||||
}
|
||||
@@ -289,6 +362,13 @@ export class KanbanSingleView extends SingleViewBase<KanbanViewData> {
|
||||
propertyGetOrCreate(columnId: string): KanbanColumn {
|
||||
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];
|
||||
|
||||
@@ -190,7 +190,7 @@ const createDragPreview = (card: KanbanCard, x: number, y: number) => {
|
||||
div.className = 'with-data-view-css-variable';
|
||||
div.style.width = `${card.getBoundingClientRect().width}px`;
|
||||
div.style.position = 'fixed';
|
||||
// div.style.pointerEvents = 'none';
|
||||
div.style.pointerEvents = 'none';
|
||||
div.style.transform = 'rotate(-3deg)';
|
||||
div.style.left = `${x}px`;
|
||||
div.style.top = `${y}px`;
|
||||
@@ -209,8 +209,12 @@ const createDragPreview = (card: KanbanCard, x: number, y: number) => {
|
||||
};
|
||||
const createDropPreview = () => {
|
||||
const div = document.createElement('div');
|
||||
div.style.height = '2px';
|
||||
div.style.borderRadius = '1px';
|
||||
div.dataset.isDropPreview = 'true';
|
||||
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.boxShadow = '0px 0px 8px 0px rgba(30, 150, 235, 0.35)';
|
||||
return {
|
||||
@@ -219,19 +223,50 @@ const createDropPreview = () => {
|
||||
self: KanbanCard | undefined,
|
||||
card?: KanbanCard
|
||||
) {
|
||||
const target = card ?? group.querySelector('.add-card');
|
||||
if (!target) {
|
||||
console.error('`target` is not found');
|
||||
return;
|
||||
}
|
||||
if (target.previousElementSibling === self || target === self) {
|
||||
if (card === self) {
|
||||
div.remove();
|
||||
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;
|
||||
}
|
||||
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() {
|
||||
div.remove();
|
||||
|
||||
@@ -11,6 +11,7 @@ import { html } from 'lit/static-html.js';
|
||||
|
||||
import { groupTraitKey } from '../../../core/group-by/trait.js';
|
||||
import type { SingleView } from '../../../core/index.js';
|
||||
import { canGroupable } from '../group-by-utils.js';
|
||||
|
||||
const styles = css`
|
||||
affine-data-view-kanban-header {
|
||||
@@ -43,7 +44,12 @@ export class KanbanHeader extends SignalWatcher(
|
||||
popMenu(popupTargetFromElement(e.target as HTMLElement), {
|
||||
options: {
|
||||
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 => {
|
||||
return menu.action({
|
||||
name: column.name$.value,
|
||||
|
||||
@@ -64,9 +64,6 @@ export class MobileTableColumnHeader extends SignalWatcher(
|
||||
};
|
||||
|
||||
private popMenu(ele?: HTMLElement) {
|
||||
const enableNumberFormatting =
|
||||
this.tableViewManager.featureFlags$.value.enable_number_formatting;
|
||||
|
||||
popMenu(popupTargetFromElement(ele ?? this), {
|
||||
options: {
|
||||
title: {
|
||||
@@ -76,41 +73,36 @@ export class MobileTableColumnHeader extends SignalWatcher(
|
||||
inputConfig(this.column),
|
||||
typeConfig(this.column),
|
||||
// Number format begin
|
||||
...(enableNumberFormatting
|
||||
? [
|
||||
menu.subMenu({
|
||||
name: 'Number Format',
|
||||
hide: () =>
|
||||
!this.column.dataUpdate ||
|
||||
this.column.type$.value !== 'number',
|
||||
options: {
|
||||
title: {
|
||||
text: 'Number Format',
|
||||
menu.subMenu({
|
||||
name: 'Number Format',
|
||||
hide: () =>
|
||||
!this.column.dataUpdate || this.column.type$.value !== 'number',
|
||||
options: {
|
||||
title: {
|
||||
text: 'Number Format',
|
||||
},
|
||||
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,
|
||||
}));
|
||||
},
|
||||
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
|
||||
menu.group({
|
||||
items: [
|
||||
|
||||
@@ -205,47 +205,39 @@ export class DatabaseHeaderColumn extends SignalWatcher(
|
||||
}
|
||||
|
||||
private popMenu(ele?: HTMLElement) {
|
||||
const enableNumberFormatting =
|
||||
this.tableViewManager.featureFlags$.value.enable_number_formatting;
|
||||
|
||||
popMenu(popupTargetFromElement(ele ?? this), {
|
||||
options: {
|
||||
items: [
|
||||
inputConfig(this.column),
|
||||
typeConfig(this.column),
|
||||
// Number format begin
|
||||
...(enableNumberFormatting
|
||||
? [
|
||||
menu.subMenu({
|
||||
name: 'Number Format',
|
||||
hide: () =>
|
||||
!this.column.dataUpdate ||
|
||||
this.column.type$.value !== 'number',
|
||||
options: {
|
||||
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,
|
||||
}));
|
||||
},
|
||||
});
|
||||
}),
|
||||
],
|
||||
},
|
||||
menu.subMenu({
|
||||
name: 'Number Format',
|
||||
hide: () =>
|
||||
!this.column.dataUpdate || this.column.type$.value !== 'number',
|
||||
options: {
|
||||
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
|
||||
menu.group({
|
||||
items: [
|
||||
|
||||
@@ -205,47 +205,39 @@ export class DatabaseHeaderColumn extends SignalWatcher(
|
||||
}
|
||||
|
||||
private popMenu(ele?: HTMLElement) {
|
||||
const enableNumberFormatting =
|
||||
this.tableViewManager.featureFlags$.value.enable_number_formatting;
|
||||
|
||||
popMenu(popupTargetFromElement(ele ?? this), {
|
||||
options: {
|
||||
items: [
|
||||
inputConfig(this.column),
|
||||
typeConfig(this.column),
|
||||
// Number format begin
|
||||
...(enableNumberFormatting
|
||||
? [
|
||||
menu.subMenu({
|
||||
name: 'Number Format',
|
||||
hide: () =>
|
||||
!this.column.dataUpdate ||
|
||||
this.column.type$.value !== 'number',
|
||||
options: {
|
||||
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,
|
||||
}));
|
||||
},
|
||||
});
|
||||
}),
|
||||
],
|
||||
},
|
||||
menu.subMenu({
|
||||
name: 'Number Format',
|
||||
hide: () =>
|
||||
!this.column.dataUpdate || this.column.type$.value !== 'number',
|
||||
options: {
|
||||
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
|
||||
menu.group({
|
||||
items: [
|
||||
|
||||
@@ -337,6 +337,7 @@ export const popViewOptions = (
|
||||
const reopen = () => {
|
||||
popViewOptions(target, dataViewLogic);
|
||||
};
|
||||
let handler: ReturnType<typeof popMenu>;
|
||||
const items: MenuConfig[] = [];
|
||||
items.push(
|
||||
menu.input({
|
||||
@@ -350,16 +351,9 @@ export const popViewOptions = (
|
||||
items.push(
|
||||
menu.group({
|
||||
items: [
|
||||
menu.action({
|
||||
name: 'Layout',
|
||||
postfix: html` <div
|
||||
style="font-size: 14px;text-transform: capitalize;"
|
||||
>
|
||||
${view.type}
|
||||
</div>
|
||||
${ArrowRightSmallIcon()}`,
|
||||
select: () => {
|
||||
const viewTypes = view.manager.viewMetas.map<MenuConfig>(meta => {
|
||||
menu => {
|
||||
const viewTypeItems = menu.renderItems(
|
||||
view.manager.viewMetas.map<MenuConfig>(meta => {
|
||||
return menu => {
|
||||
if (!menu.search(meta.model.defaultName)) {
|
||||
return;
|
||||
@@ -379,10 +373,10 @@ export const popViewOptions = (
|
||||
? 'var(--affine-text-emphasis-color)'
|
||||
: 'var(--affine-text-secondary-color)',
|
||||
});
|
||||
const data: MenuButtonData = {
|
||||
const buttonData: MenuButtonData = {
|
||||
content: () => html`
|
||||
<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}">
|
||||
${renderUniLit(meta.renderer.icon)}
|
||||
@@ -392,7 +386,7 @@ export const popViewOptions = (
|
||||
`,
|
||||
select: () => {
|
||||
const id = view.manager.currentViewId$.value;
|
||||
if (!id) {
|
||||
if (!id || meta.type === view.type) {
|
||||
return;
|
||||
}
|
||||
view.manager.viewChangeType(id, meta.type);
|
||||
@@ -403,55 +397,35 @@ export const popViewOptions = (
|
||||
const containerStyle = styleMap({
|
||||
flex: '1',
|
||||
});
|
||||
return html` <affine-menu-button
|
||||
return html`<affine-menu-button
|
||||
style="${containerStyle}"
|
||||
.data="${data}"
|
||||
.data="${buttonData}"
|
||||
.menu="${menu}"
|
||||
></affine-menu-button>`;
|
||||
};
|
||||
});
|
||||
const subHandler = popMenu(target, {
|
||||
options: {
|
||||
title: {
|
||||
onBack: reopen,
|
||||
text: 'Layout',
|
||||
},
|
||||
items: [
|
||||
menu => {
|
||||
const result = menu.renderItems(viewTypes);
|
||||
if (result.length) {
|
||||
return html` <div style="display: flex">${result}</div>`;
|
||||
}
|
||||
return html``;
|
||||
},
|
||||
// menu.toggleSwitch({
|
||||
// name: 'Show block icon',
|
||||
// on: true,
|
||||
// onChange: value => {
|
||||
// console.log(value);
|
||||
// },
|
||||
// }),
|
||||
// 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(),
|
||||
}),
|
||||
})
|
||||
);
|
||||
if (!viewTypeItems.length) {
|
||||
return html``;
|
||||
}
|
||||
return html`
|
||||
<div style="display:flex;align-items:center;gap:8px;padding:0 2px;">
|
||||
<div
|
||||
style="display:flex;align-items:center;color:var(--affine-icon-color);"
|
||||
>
|
||||
${LayoutIcon()}
|
||||
</div>
|
||||
<div
|
||||
style="font-size:14px;line-height:22px;color:var(--affine-text-secondary-color);"
|
||||
>
|
||||
Layout
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;margin-top:8px;">
|
||||
${viewTypeItems}
|
||||
</div>
|
||||
`;
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
@@ -486,7 +460,6 @@ export const popViewOptions = (
|
||||
],
|
||||
})
|
||||
);
|
||||
let handler: ReturnType<typeof popMenu>;
|
||||
handler = popMenu(target, {
|
||||
options: {
|
||||
title: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
describe('splitTextByUrl', () => {
|
||||
test('should split text and keep url part as link segment', () => {
|
||||
expect(splitTextByUrl('hi - https://google.com')).toEqual([
|
||||
{ text: 'hi - ' },
|
||||
{
|
||||
text: 'https://google.com',
|
||||
link: 'https://google.com',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('should support prefixed url token without swallowing prefix text', () => {
|
||||
expect(splitTextByUrl('-https://google.com')).toEqual([
|
||||
{ text: '-' },
|
||||
{
|
||||
text: 'https://google.com',
|
||||
link: 'https://google.com',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('should trim tail punctuations from url token', () => {
|
||||
expect(splitTextByUrl('visit https://google.com, now')).toEqual([
|
||||
{ text: 'visit ' },
|
||||
{
|
||||
text: 'https://google.com',
|
||||
link: 'https://google.com',
|
||||
},
|
||||
{ text: ', now' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('should convert domain token in plain text', () => {
|
||||
expect(splitTextByUrl('google.com and text')).toEqual([
|
||||
{
|
||||
text: 'google.com',
|
||||
link: 'https://google.com',
|
||||
},
|
||||
{ text: ' and text' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('should normalize www domain token link while preserving display text', () => {
|
||||
expect(splitTextByUrl('www.google.com')).toEqual([
|
||||
{
|
||||
text: 'www.google.com',
|
||||
link: 'https://www.google.com',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,6 @@ import { type Store, StoreExtension } from '@blocksuite/store';
|
||||
import { type Signal, signal } from '@preact/signals-core';
|
||||
|
||||
export interface BlockSuiteFlags {
|
||||
enable_database_number_formatting: boolean;
|
||||
enable_database_attachment_note: boolean;
|
||||
enable_database_full_width: boolean;
|
||||
enable_block_query: boolean;
|
||||
@@ -28,7 +27,6 @@ export class FeatureFlagService extends StoreExtension {
|
||||
static override key = 'feature-flag-server';
|
||||
|
||||
private readonly _flags: Signal<BlockSuiteFlags> = signal({
|
||||
enable_database_number_formatting: false,
|
||||
enable_database_attachment_note: false,
|
||||
enable_database_full_width: false,
|
||||
enable_block_query: false,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { FontFamily, FontStyle, FontWeight } from '@blocksuite/affine-model';
|
||||
import { createIdentifier } from '@blocksuite/global/di';
|
||||
import { IS_FIREFOX } from '@blocksuite/global/env';
|
||||
import { LifeCycleWatcher } from '@blocksuite/std';
|
||||
@@ -20,33 +21,171 @@ const initFontFace = IS_FIREFOX
|
||||
export class FontLoaderService extends LifeCycleWatcher {
|
||||
static override readonly key = 'font-loader';
|
||||
|
||||
private static readonly DEFERRED_LOAD_DELAY_MS = 5000;
|
||||
|
||||
private static readonly DEFERRED_LOAD_BATCH_SIZE = 4;
|
||||
|
||||
private static readonly DEFERRED_LOAD_BATCH_INTERVAL_MS = 1000;
|
||||
|
||||
private _idleLoadTaskId: number | null = null;
|
||||
|
||||
private _lazyLoadTimeoutId: number | null = null;
|
||||
|
||||
private _deferredFontsQueue: FontConfig[] = [];
|
||||
|
||||
private _deferredFontsCursor = 0;
|
||||
|
||||
private readonly _loadedFontKeys = new Set<string>();
|
||||
|
||||
readonly fontFaces: FontFace[] = [];
|
||||
|
||||
get ready() {
|
||||
return Promise.all(this.fontFaces.map(fontFace => fontFace.loaded));
|
||||
}
|
||||
|
||||
private readonly _fontKey = ({ font, weight, style, url }: FontConfig) => {
|
||||
return `${font}:${weight}:${style}:${url}`;
|
||||
};
|
||||
|
||||
private readonly _isCriticalCanvasFont = ({
|
||||
font,
|
||||
weight,
|
||||
style,
|
||||
}: FontConfig) => {
|
||||
if (style !== FontStyle.Normal) return false;
|
||||
|
||||
if (font === FontFamily.Poppins) {
|
||||
return (
|
||||
weight === FontWeight.Regular ||
|
||||
weight === FontWeight.Medium ||
|
||||
weight === FontWeight.SemiBold
|
||||
);
|
||||
}
|
||||
|
||||
if (font === FontFamily.Inter) {
|
||||
return weight === FontWeight.Regular || weight === FontWeight.SemiBold;
|
||||
}
|
||||
|
||||
if (font === FontFamily.Kalam) {
|
||||
// Mindmap style four uses bold Kalam text.
|
||||
// We map to SemiBold because this is the strongest shipped Kalam weight.
|
||||
return weight === FontWeight.SemiBold;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
private readonly _scheduleDeferredLoad = (fonts: FontConfig[]) => {
|
||||
if (fonts.length === 0 || typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
this._deferredFontsQueue = fonts;
|
||||
this._deferredFontsCursor = 0;
|
||||
|
||||
const win = window as Window & {
|
||||
requestIdleCallback?: (
|
||||
callback: () => void,
|
||||
options?: { timeout?: number }
|
||||
) => number;
|
||||
cancelIdleCallback?: (handle: number) => void;
|
||||
};
|
||||
|
||||
const scheduleBatch = (delayMs: number) => {
|
||||
this._lazyLoadTimeoutId = window.setTimeout(() => {
|
||||
this._lazyLoadTimeoutId = null;
|
||||
const runBatch = () => {
|
||||
this._idleLoadTaskId = null;
|
||||
|
||||
const start = this._deferredFontsCursor;
|
||||
const end = Math.min(
|
||||
start + FontLoaderService.DEFERRED_LOAD_BATCH_SIZE,
|
||||
this._deferredFontsQueue.length
|
||||
);
|
||||
const batch = this._deferredFontsQueue.slice(start, end);
|
||||
this._deferredFontsCursor = end;
|
||||
this.load(batch);
|
||||
|
||||
if (this._deferredFontsCursor < this._deferredFontsQueue.length) {
|
||||
scheduleBatch(FontLoaderService.DEFERRED_LOAD_BATCH_INTERVAL_MS);
|
||||
}
|
||||
};
|
||||
|
||||
if (typeof win.requestIdleCallback === 'function') {
|
||||
this._idleLoadTaskId = win.requestIdleCallback(runBatch, {
|
||||
timeout: 2000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
runBatch();
|
||||
}, delayMs);
|
||||
};
|
||||
|
||||
scheduleBatch(FontLoaderService.DEFERRED_LOAD_DELAY_MS);
|
||||
};
|
||||
|
||||
private readonly _cancelDeferredLoad = () => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const win = window as Window & {
|
||||
cancelIdleCallback?: (handle: number) => void;
|
||||
};
|
||||
|
||||
if (
|
||||
this._idleLoadTaskId !== null &&
|
||||
typeof win.cancelIdleCallback === 'function'
|
||||
) {
|
||||
win.cancelIdleCallback(this._idleLoadTaskId);
|
||||
this._idleLoadTaskId = null;
|
||||
}
|
||||
if (this._lazyLoadTimeoutId !== null) {
|
||||
window.clearTimeout(this._lazyLoadTimeoutId);
|
||||
this._lazyLoadTimeoutId = null;
|
||||
}
|
||||
this._deferredFontsQueue = [];
|
||||
this._deferredFontsCursor = 0;
|
||||
};
|
||||
|
||||
load(fonts: FontConfig[]) {
|
||||
this.fontFaces.push(
|
||||
...fonts.map(font => {
|
||||
const fontFace = initFontFace(font);
|
||||
document.fonts.add(fontFace);
|
||||
fontFace.load().catch(console.error);
|
||||
return fontFace;
|
||||
})
|
||||
);
|
||||
for (const font of fonts) {
|
||||
const key = this._fontKey(font);
|
||||
if (this._loadedFontKeys.has(key)) {
|
||||
continue;
|
||||
}
|
||||
this._loadedFontKeys.add(key);
|
||||
const fontFace = initFontFace(font);
|
||||
document.fonts.add(fontFace);
|
||||
fontFace.load().catch(console.error);
|
||||
this.fontFaces.push(fontFace);
|
||||
}
|
||||
}
|
||||
|
||||
override mounted() {
|
||||
const config = this.std.getOptional(FontConfigIdentifier);
|
||||
if (config) {
|
||||
this.load(config);
|
||||
if (!config || config.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const criticalFonts = config.filter(this._isCriticalCanvasFont);
|
||||
const eagerFonts =
|
||||
criticalFonts.length > 0 ? criticalFonts : config.slice(0, 3);
|
||||
const eagerFontKeySet = new Set(eagerFonts.map(this._fontKey));
|
||||
const deferredFonts = config.filter(
|
||||
font => !eagerFontKeySet.has(this._fontKey(font))
|
||||
);
|
||||
|
||||
this.load(eagerFonts);
|
||||
this._scheduleDeferredLoad(deferredFonts);
|
||||
}
|
||||
|
||||
override unmounted() {
|
||||
this.fontFaces.forEach(fontFace => document.fonts.delete(fontFace));
|
||||
this._cancelDeferredLoad();
|
||||
for (const fontFace of this.fontFaces) {
|
||||
document.fonts.delete(fontFace);
|
||||
}
|
||||
this.fontFaces.splice(0, this.fontFaces.length);
|
||||
this._loadedFontKeys.clear();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -95,28 +95,107 @@ export function isValidUrl(str: string, baseUrl = location.origin) {
|
||||
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
|
||||
const COMMON_TLDS = new Set([
|
||||
'com',
|
||||
'org',
|
||||
'net',
|
||||
'edu',
|
||||
'gov',
|
||||
'co',
|
||||
'io',
|
||||
'me',
|
||||
'moe',
|
||||
'mil',
|
||||
'top',
|
||||
'dev',
|
||||
'xyz',
|
||||
'info',
|
||||
'cat',
|
||||
'ru',
|
||||
'co',
|
||||
'com',
|
||||
'de',
|
||||
'dev',
|
||||
'edu',
|
||||
'eu',
|
||||
'gov',
|
||||
'info',
|
||||
'io',
|
||||
'jp',
|
||||
'uk',
|
||||
'me',
|
||||
'mil',
|
||||
'moe',
|
||||
'net',
|
||||
'org',
|
||||
'pro',
|
||||
'ru',
|
||||
'top',
|
||||
'uk',
|
||||
'xyz',
|
||||
]);
|
||||
|
||||
function isCommonTLD(url: URL) {
|
||||
|
||||
@@ -14,6 +14,17 @@ import {
|
||||
} from '../config.js';
|
||||
import type { AffineDragHandleWidget } from '../drag-handle.js';
|
||||
|
||||
type HoveredElemArea = {
|
||||
left: number;
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
width: number;
|
||||
height: number;
|
||||
padding: number;
|
||||
containerWidth: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Used to control the drag handle visibility in edgeless mode
|
||||
*
|
||||
@@ -21,6 +32,52 @@ import type { AffineDragHandleWidget } from '../drag-handle.js';
|
||||
* 2. Multiple selection is not supported
|
||||
*/
|
||||
export class EdgelessWatcher {
|
||||
private _pendingHoveredElemArea: HoveredElemArea | null = null;
|
||||
|
||||
private _lastAppliedHoveredElemArea: HoveredElemArea | null = null;
|
||||
|
||||
private _showDragHandleRafId: number | null = null;
|
||||
|
||||
private _surfaceElementUpdatedRafId: number | null = null;
|
||||
|
||||
private readonly _cloneArea = (area: HoveredElemArea): HoveredElemArea => ({
|
||||
left: area.left,
|
||||
top: area.top,
|
||||
right: area.right,
|
||||
bottom: area.bottom,
|
||||
width: area.width,
|
||||
height: area.height,
|
||||
padding: area.padding,
|
||||
containerWidth: area.containerWidth,
|
||||
});
|
||||
|
||||
private readonly _isAreaEqual = (
|
||||
left: HoveredElemArea | null,
|
||||
right: HoveredElemArea | null
|
||||
) => {
|
||||
if (!left || !right) return false;
|
||||
return (
|
||||
left.left === right.left &&
|
||||
left.top === right.top &&
|
||||
left.right === right.right &&
|
||||
left.bottom === right.bottom &&
|
||||
left.width === right.width &&
|
||||
left.height === right.height &&
|
||||
left.padding === right.padding &&
|
||||
left.containerWidth === right.containerWidth
|
||||
);
|
||||
};
|
||||
|
||||
private readonly _scheduleShowDragHandleFromSurfaceUpdate = () => {
|
||||
if (this._surfaceElementUpdatedRafId !== null) return;
|
||||
|
||||
this._surfaceElementUpdatedRafId = requestAnimationFrame(() => {
|
||||
this._surfaceElementUpdatedRafId = null;
|
||||
if (!this.widget.isGfxDragHandleVisible) return;
|
||||
this._showDragHandle();
|
||||
});
|
||||
};
|
||||
|
||||
private readonly _handleEdgelessToolUpdated = (
|
||||
newTool: ToolOptionWithType
|
||||
) => {
|
||||
@@ -43,46 +100,123 @@ export class EdgelessWatcher {
|
||||
}
|
||||
|
||||
if (
|
||||
this.widget.center[0] !== center[0] &&
|
||||
this.widget.center[0] !== center[0] ||
|
||||
this.widget.center[1] !== center[1]
|
||||
) {
|
||||
this.widget.center = [...center];
|
||||
}
|
||||
|
||||
if (this.widget.isGfxDragHandleVisible) {
|
||||
this._showDragHandle();
|
||||
this._updateDragHoverRectTopLevelBlock();
|
||||
const area = this.hoveredElemArea;
|
||||
this._showDragHandle(area);
|
||||
this._updateDragHoverRectTopLevelBlock(area);
|
||||
} else if (this.widget.activeDragHandle) {
|
||||
this.widget.hide();
|
||||
}
|
||||
};
|
||||
|
||||
private readonly _showDragHandle = () => {
|
||||
if (!this.widget.anchorBlockId) return;
|
||||
private readonly _flushShowDragHandle = () => {
|
||||
this._showDragHandleRafId = null;
|
||||
|
||||
if (!this.widget.anchorBlockId.peek()) return;
|
||||
|
||||
const container = this.widget.dragHandleContainer;
|
||||
const grabber = this.widget.dragHandleGrabber;
|
||||
if (!container || !grabber) return;
|
||||
|
||||
const area = this.hoveredElemArea;
|
||||
const area = this._pendingHoveredElemArea ?? this.hoveredElemArea;
|
||||
this._pendingHoveredElemArea = null;
|
||||
if (!area) return;
|
||||
|
||||
container.style.transition = 'none';
|
||||
container.style.paddingTop = `0px`;
|
||||
container.style.paddingBottom = `0px`;
|
||||
container.style.left = `${area.left}px`;
|
||||
container.style.top = `${area.top}px`;
|
||||
container.style.display = 'flex';
|
||||
if (
|
||||
this.widget.isGfxDragHandleVisible &&
|
||||
this._isAreaEqual(this._lastAppliedHoveredElemArea, area)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (container.style.transition !== 'none') {
|
||||
container.style.transition = 'none';
|
||||
}
|
||||
const nextPaddingTop = '0px';
|
||||
if (container.style.paddingTop !== nextPaddingTop) {
|
||||
container.style.paddingTop = nextPaddingTop;
|
||||
}
|
||||
const nextPaddingBottom = '0px';
|
||||
if (container.style.paddingBottom !== nextPaddingBottom) {
|
||||
container.style.paddingBottom = nextPaddingBottom;
|
||||
}
|
||||
const nextLeft = `${area.left}px`;
|
||||
if (container.style.left !== nextLeft) {
|
||||
container.style.left = nextLeft;
|
||||
}
|
||||
const nextTop = `${area.top}px`;
|
||||
if (container.style.top !== nextTop) {
|
||||
container.style.top = nextTop;
|
||||
}
|
||||
if (container.style.display !== 'flex') {
|
||||
container.style.display = 'flex';
|
||||
}
|
||||
|
||||
this.widget.handleAnchorModelDisposables();
|
||||
|
||||
this.widget.activeDragHandle = 'gfx';
|
||||
this._lastAppliedHoveredElemArea = this._cloneArea(area);
|
||||
};
|
||||
|
||||
private readonly _updateDragHoverRectTopLevelBlock = () => {
|
||||
private readonly _showDragHandle = (area?: HoveredElemArea | null) => {
|
||||
const nextArea = area ?? this.hoveredElemArea;
|
||||
this._pendingHoveredElemArea = nextArea;
|
||||
if (!this._pendingHoveredElemArea) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
this.widget.isGfxDragHandleVisible &&
|
||||
this._showDragHandleRafId === null &&
|
||||
this._isAreaEqual(
|
||||
this._lastAppliedHoveredElemArea,
|
||||
this._pendingHoveredElemArea
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (this._showDragHandleRafId !== null) {
|
||||
return;
|
||||
}
|
||||
this._showDragHandleRafId = requestAnimationFrame(
|
||||
this._flushShowDragHandle
|
||||
);
|
||||
};
|
||||
|
||||
private readonly _updateDragHoverRectTopLevelBlock = (
|
||||
area?: HoveredElemArea | null
|
||||
) => {
|
||||
if (!this.widget.dragHoverRect) return;
|
||||
|
||||
this.widget.dragHoverRect = this.hoveredElemAreaRect;
|
||||
const nextArea = area ?? this.hoveredElemArea;
|
||||
if (!nextArea) {
|
||||
this.widget.dragHoverRect = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const nextRect = new Rect(
|
||||
nextArea.left,
|
||||
nextArea.top,
|
||||
nextArea.right,
|
||||
nextArea.bottom
|
||||
);
|
||||
const prevRect = this.widget.dragHoverRect;
|
||||
if (
|
||||
prevRect &&
|
||||
prevRect.left === nextRect.left &&
|
||||
prevRect.top === nextRect.top &&
|
||||
prevRect.width === nextRect.width &&
|
||||
prevRect.height === nextRect.height
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.widget.dragHoverRect = nextRect;
|
||||
};
|
||||
|
||||
get gfx() {
|
||||
@@ -123,7 +257,7 @@ export class EdgelessWatcher {
|
||||
return new Rect(area.left, area.top, area.right, area.bottom);
|
||||
}
|
||||
|
||||
get hoveredElemArea() {
|
||||
get hoveredElemArea(): HoveredElemArea | null {
|
||||
const edgelessElement = this.widget.anchorEdgelessElement.peek();
|
||||
|
||||
if (!edgelessElement) return null;
|
||||
@@ -174,6 +308,19 @@ export class EdgelessWatcher {
|
||||
viewport.viewportUpdated.subscribe(this._handleEdgelessViewPortUpdated)
|
||||
);
|
||||
|
||||
disposables.add(() => {
|
||||
if (this._showDragHandleRafId !== null) {
|
||||
cancelAnimationFrame(this._showDragHandleRafId);
|
||||
this._showDragHandleRafId = null;
|
||||
}
|
||||
if (this._surfaceElementUpdatedRafId !== null) {
|
||||
cancelAnimationFrame(this._surfaceElementUpdatedRafId);
|
||||
this._surfaceElementUpdatedRafId = null;
|
||||
}
|
||||
this._pendingHoveredElemArea = null;
|
||||
this._lastAppliedHoveredElemArea = null;
|
||||
});
|
||||
|
||||
disposables.add(
|
||||
selection.slots.updated.subscribe(() => {
|
||||
this.updateAnchorElement();
|
||||
@@ -216,7 +363,7 @@ export class EdgelessWatcher {
|
||||
this.widget.hide();
|
||||
}
|
||||
if (payload.type === 'update') {
|
||||
this._showDragHandle();
|
||||
this._scheduleShowDragHandleFromSurfaceUpdate();
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -224,9 +371,10 @@ export class EdgelessWatcher {
|
||||
|
||||
if (surface) {
|
||||
disposables.add(
|
||||
surface.elementUpdated.subscribe(() => {
|
||||
surface.elementUpdated.subscribe(({ id }) => {
|
||||
if (this.widget.isGfxDragHandleVisible) {
|
||||
this._showDragHandle();
|
||||
if (id !== this.widget.anchorBlockId.peek()) return;
|
||||
this._scheduleShowDragHandleFromSurfaceUpdate();
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
@@ -153,6 +153,10 @@ export class PointerEventWatcher {
|
||||
|
||||
private _lastShowedBlock: { id: string; el: BlockComponent } | null = null;
|
||||
|
||||
private _lastPointerHitBlockId: string | null = null;
|
||||
|
||||
private _lastPointerHitBlockElement: Element | null = null;
|
||||
|
||||
/**
|
||||
* When pointer move on block, should show drag handle
|
||||
* And update hover block id and path
|
||||
@@ -169,6 +173,7 @@ export class PointerEventWatcher {
|
||||
point
|
||||
);
|
||||
if (!closestBlock) {
|
||||
this._lastPointerHitBlockId = null;
|
||||
this.widget.anchorBlockId.value = null;
|
||||
return;
|
||||
}
|
||||
@@ -237,19 +242,38 @@ export class PointerEventWatcher {
|
||||
|
||||
const state = ctx.get('pointerState');
|
||||
|
||||
// When pointer is moving, should do nothing
|
||||
if (state.delta.x !== 0 && state.delta.y !== 0) return;
|
||||
|
||||
const { target } = state.raw;
|
||||
const element = captureEventTarget(target);
|
||||
// When pointer not on block or on dragging, should do nothing
|
||||
if (!element) return;
|
||||
if (!element) {
|
||||
this._lastPointerHitBlockId = null;
|
||||
this._lastPointerHitBlockElement = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// When pointer on drag handle, should do nothing
|
||||
if (element.closest('.affine-drag-handle-container')) return;
|
||||
|
||||
if (!this.widget.rootComponent) return;
|
||||
|
||||
const hitBlock = element.closest(`[${BLOCK_ID_ATTR}]`);
|
||||
const hitBlockId = hitBlock?.getAttribute(BLOCK_ID_ATTR) ?? null;
|
||||
|
||||
// Pointer move events are high-frequency. If hovered block identity is
|
||||
// unchanged and the underlying block element is the same, skip the
|
||||
// closest-note lookup.
|
||||
if (
|
||||
hitBlockId &&
|
||||
this.widget.isBlockDragHandleVisible &&
|
||||
hitBlockId === this._lastPointerHitBlockId &&
|
||||
hitBlock === this._lastPointerHitBlockElement &&
|
||||
isBlockIdEqual(this.widget.anchorBlockId.peek(), hitBlockId)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this._lastPointerHitBlockId = hitBlockId;
|
||||
this._lastPointerHitBlockElement = hitBlock;
|
||||
|
||||
// When pointer out of note block hover area or inside database, should hide drag handle
|
||||
const point = new Point(state.raw.x, state.raw.y);
|
||||
|
||||
@@ -354,6 +378,8 @@ export class PointerEventWatcher {
|
||||
reset() {
|
||||
this._lastHoveredBlockId = null;
|
||||
this._lastShowedBlock = null;
|
||||
this._lastPointerHitBlockId = null;
|
||||
this._lastPointerHitBlockElement = null;
|
||||
}
|
||||
|
||||
watch() {
|
||||
|
||||
@@ -10,25 +10,15 @@ import type { InlineRange } from '../types.js';
|
||||
import { deltaInsertsToChunks } from '../utils/delta-convert.js';
|
||||
|
||||
export class RenderService<TextAttributes extends BaseTextAttributes> {
|
||||
private readonly _onYTextChange = (
|
||||
_: Y.YTextEvent,
|
||||
transaction: Y.Transaction
|
||||
) => {
|
||||
this.editor.slots.textChange.next();
|
||||
private _pendingRemoteInlineRangeSync = false;
|
||||
|
||||
const yText = this.editor.yText;
|
||||
private _carriageReturnValidationCounter = 0;
|
||||
|
||||
if (yText.toString().includes('\r')) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.InlineEditorError,
|
||||
'yText must not contain "\\r" because it will break the range synchronization'
|
||||
);
|
||||
}
|
||||
|
||||
this.render();
|
||||
private _renderVersion = 0;
|
||||
|
||||
private readonly _syncRemoteInlineRange = () => {
|
||||
const inlineRange = this.editor.inlineRange$.peek();
|
||||
if (!inlineRange || transaction.local) return;
|
||||
if (!inlineRange) return;
|
||||
|
||||
const lastStartRelativePosition = this.editor.lastStartRelativePosition;
|
||||
const lastEndRelativePosition = this.editor.lastEndRelativePosition;
|
||||
@@ -50,7 +40,7 @@ export class RenderService<TextAttributes extends BaseTextAttributes> {
|
||||
|
||||
const startIndex = absoluteStart?.index;
|
||||
const endIndex = absoluteEnd?.index;
|
||||
if (!startIndex || !endIndex) return;
|
||||
if (startIndex == null || endIndex == null) return;
|
||||
|
||||
const newInlineRange: InlineRange = {
|
||||
index: startIndex,
|
||||
@@ -59,7 +49,31 @@ export class RenderService<TextAttributes extends BaseTextAttributes> {
|
||||
if (!this.editor.isValidInlineRange(newInlineRange)) return;
|
||||
|
||||
this.editor.setInlineRange(newInlineRange);
|
||||
this.editor.syncInlineRange();
|
||||
};
|
||||
|
||||
private readonly _onYTextChange = (
|
||||
_: Y.YTextEvent,
|
||||
transaction: Y.Transaction
|
||||
) => {
|
||||
this.editor.slots.textChange.next();
|
||||
|
||||
const yText = this.editor.yText;
|
||||
|
||||
if (
|
||||
(this._carriageReturnValidationCounter++ & 0x3f) === 0 &&
|
||||
yText.toString().includes('\r')
|
||||
) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.InlineEditorError,
|
||||
'yText must not contain "\\r" because it will break the range synchronization'
|
||||
);
|
||||
}
|
||||
|
||||
if (!transaction.local) {
|
||||
this._pendingRemoteInlineRangeSync = true;
|
||||
}
|
||||
|
||||
this.render();
|
||||
};
|
||||
|
||||
mount = () => {
|
||||
@@ -70,6 +84,7 @@ export class RenderService<TextAttributes extends BaseTextAttributes> {
|
||||
editor.disposables.add({
|
||||
dispose: () => {
|
||||
yText.unobserve(this._onYTextChange);
|
||||
this._pendingRemoteInlineRangeSync = false;
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -82,6 +97,7 @@ export class RenderService<TextAttributes extends BaseTextAttributes> {
|
||||
render = () => {
|
||||
if (!this.editor.rootElement) return;
|
||||
|
||||
const renderVersion = ++this._renderVersion;
|
||||
this._rendering = true;
|
||||
|
||||
const rootElement = this.editor.rootElement;
|
||||
@@ -152,11 +168,21 @@ export class RenderService<TextAttributes extends BaseTextAttributes> {
|
||||
this.editor
|
||||
.waitForUpdate()
|
||||
.then(() => {
|
||||
if (renderVersion !== this._renderVersion) return;
|
||||
if (this._pendingRemoteInlineRangeSync) {
|
||||
this._pendingRemoteInlineRangeSync = false;
|
||||
this._syncRemoteInlineRange();
|
||||
}
|
||||
this._rendering = false;
|
||||
this.editor.slots.renderComplete.next();
|
||||
this.editor.syncInlineRange();
|
||||
})
|
||||
.catch(console.error);
|
||||
.catch(error => {
|
||||
if (renderVersion === this._renderVersion) {
|
||||
this._rendering = false;
|
||||
}
|
||||
console.error(error);
|
||||
});
|
||||
};
|
||||
|
||||
rerenderWholeEditor = () => {
|
||||
|
||||
@@ -9,7 +9,12 @@ import {
|
||||
isVElement,
|
||||
isVLine,
|
||||
} from './guard.js';
|
||||
import { calculateTextLength, getTextNodesFromElement } from './text.js';
|
||||
import {
|
||||
calculateTextLength,
|
||||
getInlineRootTextCache,
|
||||
getTextNodesFromElement,
|
||||
invalidateInlineRootTextCache,
|
||||
} from './text.js';
|
||||
|
||||
export function nativePointToTextPoint(
|
||||
node: unknown,
|
||||
@@ -67,19 +72,6 @@ export function textPointToDomPoint(
|
||||
|
||||
if (!rootElement.contains(text)) return null;
|
||||
|
||||
const texts = getTextNodesFromElement(rootElement);
|
||||
if (texts.length === 0) return null;
|
||||
|
||||
const goalIndex = texts.indexOf(text);
|
||||
let index = 0;
|
||||
for (const text of texts.slice(0, goalIndex)) {
|
||||
index += calculateTextLength(text);
|
||||
}
|
||||
|
||||
if (text.wholeText !== ZERO_WIDTH_FOR_EMPTY_LINE) {
|
||||
index += offset;
|
||||
}
|
||||
|
||||
const textParentElement = text.parentElement;
|
||||
if (!textParentElement) {
|
||||
throw new BlockSuiteError(
|
||||
@@ -97,9 +89,44 @@ export function textPointToDomPoint(
|
||||
);
|
||||
}
|
||||
|
||||
const textOffset = text.wholeText === ZERO_WIDTH_FOR_EMPTY_LINE ? 0 : offset;
|
||||
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
const { textNodes, textNodeIndexMap, prefixLengths, lineIndexMap } =
|
||||
getInlineRootTextCache(rootElement);
|
||||
if (textNodes.length === 0) return null;
|
||||
|
||||
const goalIndex = textNodeIndexMap.get(text);
|
||||
const lineIndex = lineIndexMap.get(lineElement);
|
||||
if (goalIndex !== undefined && lineIndex !== undefined) {
|
||||
const index = (prefixLengths[goalIndex] ?? 0) + textOffset;
|
||||
return { text, index: index + lineIndex };
|
||||
}
|
||||
|
||||
if (attempt === 0) {
|
||||
// MutationObserver marks cache dirty asynchronously; force one sync retry
|
||||
// when a newly-added node is queried within the same task.
|
||||
invalidateInlineRootTextCache(rootElement);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to linear scan when cache still misses. This keeps behavior
|
||||
// stable even if MutationObserver-based invalidation lags behind.
|
||||
const texts = getTextNodesFromElement(rootElement);
|
||||
if (texts.length === 0) return null;
|
||||
|
||||
const goalIndex = texts.indexOf(text);
|
||||
if (goalIndex < 0) return null;
|
||||
|
||||
let index = textOffset;
|
||||
for (const beforeText of texts.slice(0, goalIndex)) {
|
||||
index += calculateTextLength(beforeText);
|
||||
}
|
||||
|
||||
const lineIndex = Array.from(rootElement.querySelectorAll('v-line')).indexOf(
|
||||
lineElement
|
||||
);
|
||||
if (lineIndex < 0) return null;
|
||||
|
||||
return { text, index: index + lineIndex };
|
||||
}
|
||||
|
||||
@@ -8,6 +8,92 @@ export function calculateTextLength(text: Text): number {
|
||||
}
|
||||
}
|
||||
|
||||
type InlineRootTextCache = {
|
||||
dirty: boolean;
|
||||
observer: MutationObserver | null;
|
||||
textNodes: Text[];
|
||||
textNodeIndexMap: WeakMap<Text, number>;
|
||||
prefixLengths: number[];
|
||||
lineIndexMap: WeakMap<Element, number>;
|
||||
};
|
||||
|
||||
const inlineRootTextCaches = new WeakMap<HTMLElement, InlineRootTextCache>();
|
||||
|
||||
const buildInlineRootTextCache = (
|
||||
rootElement: HTMLElement,
|
||||
cache: InlineRootTextCache
|
||||
) => {
|
||||
const textSpanElements = Array.from(
|
||||
rootElement.querySelectorAll('[data-v-text="true"]')
|
||||
);
|
||||
const textNodes: Text[] = [];
|
||||
const textNodeIndexMap = new WeakMap<Text, number>();
|
||||
const prefixLengths: number[] = [];
|
||||
let prefixLength = 0;
|
||||
|
||||
for (const textSpanElement of textSpanElements) {
|
||||
const textNode = Array.from(textSpanElement.childNodes).find(
|
||||
(node): node is Text => node instanceof Text
|
||||
);
|
||||
if (!textNode) continue;
|
||||
prefixLengths.push(prefixLength);
|
||||
textNodeIndexMap.set(textNode, textNodes.length);
|
||||
textNodes.push(textNode);
|
||||
prefixLength += calculateTextLength(textNode);
|
||||
}
|
||||
|
||||
const lineIndexMap = new WeakMap<Element, number>();
|
||||
const lineElements = Array.from(rootElement.querySelectorAll('v-line'));
|
||||
for (const [index, line] of lineElements.entries()) {
|
||||
lineIndexMap.set(line, index);
|
||||
}
|
||||
|
||||
cache.textNodes = textNodes;
|
||||
cache.textNodeIndexMap = textNodeIndexMap;
|
||||
cache.prefixLengths = prefixLengths;
|
||||
cache.lineIndexMap = lineIndexMap;
|
||||
cache.dirty = false;
|
||||
};
|
||||
|
||||
export function invalidateInlineRootTextCache(rootElement: HTMLElement) {
|
||||
const cache = inlineRootTextCaches.get(rootElement);
|
||||
if (cache) {
|
||||
cache.dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
export function getInlineRootTextCache(rootElement: HTMLElement) {
|
||||
let cache = inlineRootTextCaches.get(rootElement);
|
||||
if (!cache) {
|
||||
cache = {
|
||||
dirty: true,
|
||||
observer: null,
|
||||
textNodes: [],
|
||||
textNodeIndexMap: new WeakMap(),
|
||||
prefixLengths: [],
|
||||
lineIndexMap: new WeakMap(),
|
||||
};
|
||||
inlineRootTextCaches.set(rootElement, cache);
|
||||
}
|
||||
|
||||
if (!cache.observer && typeof MutationObserver !== 'undefined') {
|
||||
cache.observer = new MutationObserver(() => {
|
||||
cache!.dirty = true;
|
||||
});
|
||||
cache.observer.observe(rootElement, {
|
||||
subtree: true,
|
||||
childList: true,
|
||||
characterData: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (cache.dirty) {
|
||||
buildInlineRootTextCache(rootElement, cache);
|
||||
}
|
||||
|
||||
return cache;
|
||||
}
|
||||
|
||||
export function getTextNodesFromElement(element: Element): Text[] {
|
||||
const textSpanElements = Array.from(
|
||||
element.querySelectorAll('[data-v-text="true"]')
|
||||
|
||||
@@ -47,7 +47,10 @@ describe('frame', () => {
|
||||
expect(rect!.width).toBeGreaterThan(0);
|
||||
expect(rect!.height).toBeGreaterThan(0);
|
||||
|
||||
const [titleX, titleY] = service.viewport.toModelCoord(rect!.x, rect!.y);
|
||||
const [titleX, titleY] = service.viewport.toModelCoordFromClientCoord([
|
||||
rect!.x,
|
||||
rect!.y,
|
||||
]);
|
||||
expect(titleX).toBeCloseTo(0);
|
||||
expect(titleY).toBeLessThan(0);
|
||||
|
||||
@@ -66,10 +69,11 @@ describe('frame', () => {
|
||||
if (!nestedTitle) return;
|
||||
|
||||
const nestedTitleRect = nestedTitle.getBoundingClientRect()!;
|
||||
const [nestedTitleX, nestedTitleY] = service.viewport.toModelCoord(
|
||||
nestedTitleRect.x,
|
||||
nestedTitleRect.y
|
||||
);
|
||||
const [nestedTitleX, nestedTitleY] =
|
||||
service.viewport.toModelCoordFromClientCoord([
|
||||
nestedTitleRect.x,
|
||||
nestedTitleRect.y,
|
||||
]);
|
||||
|
||||
expect(nestedTitleX).toBeGreaterThan(20);
|
||||
expect(nestedTitleY).toBeGreaterThan(20);
|
||||
|
||||
@@ -5,6 +5,14 @@ import { wait } from '../utils/common.js';
|
||||
import { getSurface } from '../utils/edgeless.js';
|
||||
import { setupEditor } from '../utils/setup.js';
|
||||
|
||||
function expectPxCloseTo(
|
||||
value: string,
|
||||
expected: number,
|
||||
precision: number = 2
|
||||
) {
|
||||
expect(Number.parseFloat(value)).toBeCloseTo(expected, precision);
|
||||
}
|
||||
|
||||
describe('Shape rendering with DOM renderer', () => {
|
||||
beforeEach(async () => {
|
||||
const cleanup = await setupEditor('edgeless', [], {
|
||||
@@ -59,7 +67,8 @@ describe('Shape rendering with DOM renderer', () => {
|
||||
);
|
||||
|
||||
expect(shapeElement).not.toBeNull();
|
||||
expect(shapeElement?.style.borderRadius).toBe('6px');
|
||||
const zoom = surfaceView.renderer.viewport.zoom;
|
||||
expectPxCloseTo(shapeElement!.style.borderRadius, 6 * zoom);
|
||||
});
|
||||
|
||||
test('should remove shape DOM node when element is deleted', async () => {
|
||||
@@ -110,8 +119,9 @@ describe('Shape rendering with DOM renderer', () => {
|
||||
);
|
||||
|
||||
expect(shapeElement).not.toBeNull();
|
||||
expect(shapeElement?.style.width).toBe('80px');
|
||||
expect(shapeElement?.style.height).toBe('60px');
|
||||
const zoom = surfaceView.renderer.viewport.zoom;
|
||||
expectPxCloseTo(shapeElement!.style.width, 80 * zoom);
|
||||
expectPxCloseTo(shapeElement!.style.height, 60 * zoom);
|
||||
});
|
||||
|
||||
test('should correctly render triangle shape', async () => {
|
||||
@@ -132,7 +142,8 @@ describe('Shape rendering with DOM renderer', () => {
|
||||
);
|
||||
|
||||
expect(shapeElement).not.toBeNull();
|
||||
expect(shapeElement?.style.width).toBe('80px');
|
||||
expect(shapeElement?.style.height).toBe('60px');
|
||||
const zoom = surfaceView.renderer.viewport.zoom;
|
||||
expectPxCloseTo(shapeElement!.style.width, 80 * zoom);
|
||||
expectPxCloseTo(shapeElement!.style.height, 60 * zoom);
|
||||
});
|
||||
});
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 24 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 8.2 KiB After Width: | Height: | Size: 24 KiB |
@@ -0,0 +1,363 @@
|
||||
import { LinkExtension } from '@blocksuite/affine-inline-link';
|
||||
import { textKeymap } from '@blocksuite/affine-inline-preset';
|
||||
import type {
|
||||
ListBlockModel,
|
||||
ParagraphBlockModel,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { insertContent } from '@blocksuite/affine-rich-text';
|
||||
import { REFERENCE_NODE } from '@blocksuite/affine-shared/consts';
|
||||
import { createDefaultDoc } from '@blocksuite/affine-shared/utils';
|
||||
import { TextSelection } from '@blocksuite/std';
|
||||
import type { InlineMarkdownMatch } from '@blocksuite/std/inline';
|
||||
import { Text } from '@blocksuite/store';
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { defaultSlashMenuConfig } from '../../../../affine/widgets/slash-menu/src/config.js';
|
||||
import type {
|
||||
SlashMenuActionItem,
|
||||
SlashMenuItem,
|
||||
} from '../../../../affine/widgets/slash-menu/src/types.js';
|
||||
import { wait } from '../utils/common.js';
|
||||
import { addNote } from '../utils/edgeless.js';
|
||||
import { setupEditor } from '../utils/setup.js';
|
||||
|
||||
type RichTextElement = HTMLElement & {
|
||||
inlineEditor: {
|
||||
getFormat: (range: {
|
||||
index: number;
|
||||
length: number;
|
||||
}) => Record<string, unknown>;
|
||||
getInlineRange: () => { index: number; length: number } | null;
|
||||
setInlineRange: (range: { index: number; length: number }) => void;
|
||||
yTextString: string;
|
||||
};
|
||||
markdownMatches: InlineMarkdownMatch[];
|
||||
undoManager: {
|
||||
stopCapturing: () => void;
|
||||
};
|
||||
};
|
||||
|
||||
function findSlashActionItem(
|
||||
items: SlashMenuItem[],
|
||||
name: string
|
||||
): SlashMenuActionItem {
|
||||
const item = items.find(entry => entry.name === name);
|
||||
if (!item || !('action' in item)) {
|
||||
throw new Error(`Cannot find slash-menu action: ${name}`);
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
function getRichTextByBlockId(blockId: string): RichTextElement {
|
||||
const block = editor.host?.view.getBlock(blockId) as HTMLElement | null;
|
||||
if (!block) {
|
||||
throw new Error(`Cannot find block view: ${blockId}`);
|
||||
}
|
||||
const richText = block.querySelector('rich-text') as RichTextElement | null;
|
||||
if (!richText) {
|
||||
throw new Error(`Cannot find rich-text for block: ${blockId}`);
|
||||
}
|
||||
return richText;
|
||||
}
|
||||
|
||||
async function createParagraph(text = '') {
|
||||
const noteId = addNote(doc);
|
||||
const note = doc.getBlock(noteId)?.model;
|
||||
if (!note) {
|
||||
throw new Error('Cannot find note model');
|
||||
}
|
||||
const paragraph = note.children[0] as ParagraphBlockModel | undefined;
|
||||
if (!paragraph) {
|
||||
throw new Error('Cannot find paragraph model');
|
||||
}
|
||||
if (text) {
|
||||
doc.updateBlock(paragraph, {
|
||||
text: new Text(text),
|
||||
});
|
||||
}
|
||||
await wait();
|
||||
return {
|
||||
noteId,
|
||||
paragraphId: paragraph.id,
|
||||
};
|
||||
}
|
||||
|
||||
function setTextSelection(blockId: string, index: number, length: number) {
|
||||
const to = length
|
||||
? {
|
||||
blockId,
|
||||
index: index + length,
|
||||
length: 0,
|
||||
}
|
||||
: null;
|
||||
const selection = editor.host?.selection.create(TextSelection, {
|
||||
from: {
|
||||
blockId,
|
||||
index,
|
||||
length: 0,
|
||||
},
|
||||
to,
|
||||
});
|
||||
if (!selection) {
|
||||
throw new Error('Cannot create text selection');
|
||||
}
|
||||
editor.host?.selection.setGroup('note', [selection]);
|
||||
const richText = getRichTextByBlockId(blockId);
|
||||
richText.inlineEditor.setInlineRange({ index, length });
|
||||
}
|
||||
|
||||
async function triggerMarkdown(
|
||||
blockId: string,
|
||||
input: string,
|
||||
matcherName: string
|
||||
) {
|
||||
const model = doc.getBlock(blockId)?.model as ParagraphBlockModel | undefined;
|
||||
if (!model) {
|
||||
throw new Error(`Cannot find paragraph model: ${blockId}`);
|
||||
}
|
||||
doc.updateBlock(model, {
|
||||
text: new Text(input),
|
||||
});
|
||||
await wait();
|
||||
|
||||
const richText = getRichTextByBlockId(blockId);
|
||||
const matcher = richText.markdownMatches.find(
|
||||
item => item.name === matcherName
|
||||
);
|
||||
if (!matcher) {
|
||||
throw new Error(`Cannot find markdown matcher: ${matcherName}`);
|
||||
}
|
||||
const inlineRange = { index: input.length, length: 0 };
|
||||
setTextSelection(blockId, inlineRange.index, 0);
|
||||
|
||||
matcher.action({
|
||||
inlineEditor: richText.inlineEditor as any,
|
||||
prefixText: input,
|
||||
inlineRange,
|
||||
pattern: matcher.pattern,
|
||||
undoManager: richText.undoManager as any,
|
||||
});
|
||||
|
||||
await wait();
|
||||
}
|
||||
|
||||
function mockKeyboardContext() {
|
||||
const preventDefault = vi.fn();
|
||||
const ctx = {
|
||||
get(key: string) {
|
||||
if (key === 'keyboardState') {
|
||||
return { raw: { preventDefault } };
|
||||
}
|
||||
throw new Error(`Unexpected state key: ${key}`);
|
||||
},
|
||||
};
|
||||
return { ctx: ctx as any, preventDefault };
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
const cleanup = await setupEditor('page', [LinkExtension]);
|
||||
return cleanup;
|
||||
});
|
||||
|
||||
describe('markdown/list/paragraph/quote/code/link', () => {
|
||||
test('markdown list shortcut converts to todo list and keeps checked state', async () => {
|
||||
const { noteId, paragraphId } = await createParagraph();
|
||||
await triggerMarkdown(paragraphId, '[x] ', 'list');
|
||||
|
||||
const note = doc.getBlock(noteId)?.model;
|
||||
if (!note) {
|
||||
throw new Error('Cannot find note model');
|
||||
}
|
||||
const model = note.children[0] as ListBlockModel;
|
||||
expect(model.flavour).toBe('affine:list');
|
||||
expect(model.props.type).toBe('todo');
|
||||
expect(model.props.checked).toBe(true);
|
||||
});
|
||||
|
||||
test('markdown heading and quote shortcuts convert paragraph type', async () => {
|
||||
const { noteId: headingNoteId, paragraphId: headingParagraphId } =
|
||||
await createParagraph();
|
||||
await triggerMarkdown(headingParagraphId, '# ', 'heading');
|
||||
const headingNote = doc.getBlock(headingNoteId)?.model;
|
||||
if (!headingNote) {
|
||||
throw new Error('Cannot find heading note model');
|
||||
}
|
||||
const headingModel = headingNote.children[0] as ParagraphBlockModel;
|
||||
expect(headingModel.flavour).toBe('affine:paragraph');
|
||||
expect(headingModel.props.type).toBe('h1');
|
||||
|
||||
const { noteId: quoteNoteId, paragraphId: quoteParagraphId } =
|
||||
await createParagraph();
|
||||
await triggerMarkdown(quoteParagraphId, '> ', 'heading');
|
||||
const quoteNote = doc.getBlock(quoteNoteId)?.model;
|
||||
if (!quoteNote) {
|
||||
throw new Error('Cannot find quote note model');
|
||||
}
|
||||
const quoteModel = quoteNote.children[0] as ParagraphBlockModel;
|
||||
expect(quoteModel.flavour).toBe('affine:paragraph');
|
||||
expect(quoteModel.props.type).toBe('quote');
|
||||
});
|
||||
|
||||
test('markdown code shortcut converts paragraph to code block with language', async () => {
|
||||
const { noteId, paragraphId } = await createParagraph();
|
||||
await triggerMarkdown(paragraphId, '```ts ', 'code-block');
|
||||
|
||||
const note = doc.getBlock(noteId)?.model;
|
||||
if (!note) {
|
||||
throw new Error('Cannot find note model');
|
||||
}
|
||||
const model = note.children[0];
|
||||
expect(model.flavour).toBe('affine:code');
|
||||
expect((model as any).props.language).toBe('typescript');
|
||||
});
|
||||
|
||||
test('inline markdown converts style and link attributes', async () => {
|
||||
const { paragraphId: boldParagraphId } = await createParagraph();
|
||||
await triggerMarkdown(boldParagraphId, '**bold** ', 'bold');
|
||||
const boldRichText = getRichTextByBlockId(boldParagraphId);
|
||||
expect(boldRichText.inlineEditor.yTextString).toBe('bold');
|
||||
expect(
|
||||
boldRichText.inlineEditor.getFormat({ index: 1, length: 0 })
|
||||
).toMatchObject({
|
||||
bold: true,
|
||||
});
|
||||
|
||||
const { paragraphId: codeParagraphId } = await createParagraph();
|
||||
await triggerMarkdown(codeParagraphId, '`code` ', 'code');
|
||||
const codeRichText = getRichTextByBlockId(codeParagraphId);
|
||||
expect(codeRichText.inlineEditor.yTextString).toBe('code');
|
||||
expect(
|
||||
codeRichText.inlineEditor.getFormat({ index: 1, length: 0 })
|
||||
).toMatchObject({
|
||||
code: true,
|
||||
});
|
||||
|
||||
const { paragraphId: linkParagraphId } = await createParagraph();
|
||||
await triggerMarkdown(
|
||||
linkParagraphId,
|
||||
'[AFFiNE](https://affine.pro) ',
|
||||
'link'
|
||||
);
|
||||
const linkRichText = getRichTextByBlockId(linkParagraphId);
|
||||
expect(linkRichText.inlineEditor.yTextString).toBe('AFFiNE');
|
||||
expect(
|
||||
linkRichText.inlineEditor.getFormat({ index: 1, length: 0 })
|
||||
).toMatchObject({
|
||||
link: 'https://affine.pro',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('hotkey/bracket/linked-page', () => {
|
||||
test('bracket keymap skips redundant right bracket in code block', async () => {
|
||||
const { noteId, paragraphId } = await createParagraph();
|
||||
await triggerMarkdown(paragraphId, '```ts ', 'code-block');
|
||||
const note = doc.getBlock(noteId)?.model;
|
||||
const codeId = note?.children[0]?.id;
|
||||
if (!codeId) {
|
||||
throw new Error('Cannot find code block id');
|
||||
}
|
||||
const codeModel = doc.getBlock(codeId)?.model;
|
||||
if (!codeModel) {
|
||||
throw new Error('Cannot find code block model');
|
||||
}
|
||||
const keymap = textKeymap(editor.std);
|
||||
const leftHandler = keymap['('];
|
||||
const rightHandler = keymap[')'];
|
||||
expect(leftHandler).toBeDefined();
|
||||
if (!rightHandler) {
|
||||
throw new Error('Cannot find bracket key handlers');
|
||||
}
|
||||
|
||||
doc.updateBlock(codeModel, {
|
||||
text: new Text('()'),
|
||||
});
|
||||
await wait();
|
||||
const codeRichText = getRichTextByBlockId(codeId);
|
||||
setTextSelection(codeId, 1, 0);
|
||||
const rightContext = mockKeyboardContext();
|
||||
rightHandler(rightContext.ctx);
|
||||
expect(rightContext.preventDefault).not.toHaveBeenCalled();
|
||||
expect(codeRichText.inlineEditor.yTextString).toBe('()');
|
||||
});
|
||||
|
||||
test('consecutive linked-page reference nodes render as separate references', async () => {
|
||||
const { paragraphId } = await createParagraph();
|
||||
const paragraphModel = doc.getBlock(paragraphId)?.model as
|
||||
| ParagraphBlockModel
|
||||
| undefined;
|
||||
if (!paragraphModel) {
|
||||
throw new Error('Cannot find paragraph model');
|
||||
}
|
||||
const linkedDoc = createDefaultDoc(collection, {
|
||||
title: 'Linked page',
|
||||
});
|
||||
|
||||
setTextSelection(paragraphId, 0, 0);
|
||||
insertContent(editor.std, paragraphModel, REFERENCE_NODE, {
|
||||
reference: {
|
||||
type: 'LinkedPage',
|
||||
pageId: linkedDoc.id,
|
||||
},
|
||||
});
|
||||
insertContent(editor.std, paragraphModel, REFERENCE_NODE, {
|
||||
reference: {
|
||||
type: 'LinkedPage',
|
||||
pageId: linkedDoc.id,
|
||||
},
|
||||
});
|
||||
await wait();
|
||||
expect(collection.docs.has(linkedDoc.id)).toBe(true);
|
||||
|
||||
const richText = getRichTextByBlockId(paragraphId);
|
||||
expect(richText.querySelectorAll('affine-reference').length).toBe(2);
|
||||
expect(richText.inlineEditor.yTextString.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('slash-menu action semantics', () => {
|
||||
test('date and move actions mutate block content/order as expected', async () => {
|
||||
const noteId = addNote(doc);
|
||||
const note = doc.getBlock(noteId)?.model;
|
||||
if (!note) {
|
||||
throw new Error('Cannot find note model');
|
||||
}
|
||||
const first = note.children[0] as ParagraphBlockModel;
|
||||
const secondId = doc.addBlock(
|
||||
'affine:paragraph',
|
||||
{ text: new Text('second') },
|
||||
noteId
|
||||
);
|
||||
const second = doc.getBlock(secondId)?.model as
|
||||
| ParagraphBlockModel
|
||||
| undefined;
|
||||
if (!second) {
|
||||
throw new Error('Cannot find second paragraph model');
|
||||
}
|
||||
doc.updateBlock(first, { text: new Text('first') });
|
||||
await wait();
|
||||
|
||||
const slashItems = defaultSlashMenuConfig.items;
|
||||
const items =
|
||||
typeof slashItems === 'function'
|
||||
? slashItems({ std: editor.std, model: first })
|
||||
: slashItems;
|
||||
const today = findSlashActionItem(items, 'Today');
|
||||
const moveDown = findSlashActionItem(items, 'Move Down');
|
||||
const moveUp = findSlashActionItem(items, 'Move Up');
|
||||
|
||||
moveDown.action({ std: editor.std, model: first });
|
||||
await wait();
|
||||
expect(note.children.map(child => child.id)).toEqual([second.id, first.id]);
|
||||
|
||||
moveUp.action({ std: editor.std, model: first });
|
||||
await wait();
|
||||
expect(note.children.map(child => child.id)).toEqual([first.id, second.id]);
|
||||
|
||||
setTextSelection(first.id, 0, 0);
|
||||
today.action({ std: editor.std, model: first });
|
||||
await wait();
|
||||
const richText = getRichTextByBlockId(first.id);
|
||||
expect(richText.inlineEditor.yTextString).toMatch(/\d{4}-\d{2}-\d{2}/);
|
||||
});
|
||||
});
|
||||
@@ -19,7 +19,11 @@ export default defineConfig(_configEnv =>
|
||||
browser: {
|
||||
enabled: true,
|
||||
headless: process.env.CI === 'true',
|
||||
instances: [{ browser: 'chromium' }],
|
||||
instances: [
|
||||
{ browser: 'chromium' },
|
||||
{ browser: 'firefox' },
|
||||
{ browser: 'webkit' },
|
||||
],
|
||||
provider: 'playwright',
|
||||
isolate: false,
|
||||
viewport: {
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
CREATE TABLE IF NOT EXISTS "workspace_admin_stats_daily" (
|
||||
"workspace_id" VARCHAR NOT NULL,
|
||||
"date" DATE NOT NULL,
|
||||
"snapshot_size" BIGINT NOT NULL DEFAULT 0,
|
||||
"blob_size" BIGINT NOT NULL DEFAULT 0,
|
||||
"member_count" BIGINT NOT NULL DEFAULT 0,
|
||||
"updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT "workspace_admin_stats_daily_pkey" PRIMARY KEY ("workspace_id", "date"),
|
||||
CONSTRAINT "workspace_admin_stats_daily_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "workspace_admin_stats_daily_date_idx" ON "workspace_admin_stats_daily" ("date");
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "sync_active_users_minutely" (
|
||||
"minute_ts" TIMESTAMPTZ(3) NOT NULL,
|
||||
"active_users" INTEGER NOT NULL DEFAULT 0,
|
||||
"updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT "sync_active_users_minutely_pkey" PRIMARY KEY ("minute_ts")
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "workspace_doc_view_daily" (
|
||||
"workspace_id" VARCHAR NOT NULL,
|
||||
"doc_id" VARCHAR NOT NULL,
|
||||
"date" DATE NOT NULL,
|
||||
"total_views" BIGINT NOT NULL DEFAULT 0,
|
||||
"unique_views" BIGINT NOT NULL DEFAULT 0,
|
||||
"guest_views" BIGINT NOT NULL DEFAULT 0,
|
||||
"last_accessed_at" TIMESTAMPTZ(3),
|
||||
"updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT "workspace_doc_view_daily_pkey" PRIMARY KEY ("workspace_id", "doc_id", "date"),
|
||||
CONSTRAINT "workspace_doc_view_daily_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "workspace_doc_view_daily_workspace_id_date_idx" ON "workspace_doc_view_daily" ("workspace_id", "date");
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "workspace_member_last_access" (
|
||||
"workspace_id" VARCHAR NOT NULL,
|
||||
"user_id" VARCHAR NOT NULL,
|
||||
"last_accessed_at" TIMESTAMPTZ(3) NOT NULL,
|
||||
"last_doc_id" VARCHAR,
|
||||
"updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT "workspace_member_last_access_pkey" PRIMARY KEY ("workspace_id", "user_id"),
|
||||
CONSTRAINT "workspace_member_last_access_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "workspace_member_last_access_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "workspace_member_last_access_workspace_id_last_accessed_at_idx" ON "workspace_member_last_access" ("workspace_id", "last_accessed_at" DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "workspace_member_last_access_workspace_id_last_doc_id_idx" ON "workspace_member_last_access" ("workspace_id", "last_doc_id");
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "workspace_pages_public_published_at_idx" ON "workspace_pages" ("public", "published_at");
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "ai_sessions_messages_created_at_role_idx" ON "ai_sessions_messages" ("created_at", "role");
|
||||
|
||||
DROP TRIGGER IF EXISTS user_features_set_feature_id ON "user_features";
|
||||
|
||||
DROP TRIGGER IF EXISTS workspace_features_set_feature_id ON "workspace_features";
|
||||
|
||||
DROP FUNCTION IF EXISTS set_user_feature_id_from_name();
|
||||
|
||||
DROP FUNCTION IF EXISTS set_workspace_feature_id_from_name();
|
||||
|
||||
DROP FUNCTION IF EXISTS ensure_feature_exists(TEXT);
|
||||
|
||||
ALTER TABLE
|
||||
"user_features" DROP CONSTRAINT IF EXISTS "user_features_feature_id_fkey";
|
||||
|
||||
ALTER TABLE
|
||||
"workspace_features" DROP CONSTRAINT IF EXISTS "workspace_features_feature_id_fkey";
|
||||
|
||||
DROP INDEX IF EXISTS "user_features_feature_id_idx";
|
||||
|
||||
DROP INDEX IF EXISTS "workspace_features_feature_id_idx";
|
||||
|
||||
ALTER TABLE
|
||||
"user_features" DROP COLUMN IF EXISTS "feature_id";
|
||||
|
||||
ALTER TABLE
|
||||
"workspace_features" DROP COLUMN IF EXISTS "feature_id";
|
||||
|
||||
DROP TABLE IF EXISTS "features";
|
||||
@@ -25,31 +25,32 @@ model User {
|
||||
registered Boolean @default(true)
|
||||
disabled Boolean @default(false)
|
||||
|
||||
features UserFeature[]
|
||||
userStripeCustomer UserStripeCustomer?
|
||||
workspaces WorkspaceUserRole[]
|
||||
features UserFeature[]
|
||||
userStripeCustomer UserStripeCustomer?
|
||||
workspaces WorkspaceUserRole[]
|
||||
// Invite others to join the workspace
|
||||
WorkspaceInvitations WorkspaceUserRole[] @relation("inviter")
|
||||
docPermissions WorkspaceDocUserRole[]
|
||||
connectedAccounts ConnectedAccount[]
|
||||
calendarAccounts CalendarAccount[]
|
||||
sessions UserSession[]
|
||||
aiSessions AiSession[]
|
||||
appConfigs AppConfig[]
|
||||
userSnapshots UserSnapshot[]
|
||||
createdSnapshot Snapshot[] @relation("createdSnapshot")
|
||||
updatedSnapshot Snapshot[] @relation("updatedSnapshot")
|
||||
createdUpdate Update[] @relation("createdUpdate")
|
||||
createdHistory SnapshotHistory[] @relation("createdHistory")
|
||||
createdAiJobs AiJobs[] @relation("createdAiJobs")
|
||||
WorkspaceInvitations WorkspaceUserRole[] @relation("inviter")
|
||||
docPermissions WorkspaceDocUserRole[]
|
||||
connectedAccounts ConnectedAccount[]
|
||||
calendarAccounts CalendarAccount[]
|
||||
sessions UserSession[]
|
||||
aiSessions AiSession[]
|
||||
appConfigs AppConfig[]
|
||||
userSnapshots UserSnapshot[]
|
||||
createdSnapshot Snapshot[] @relation("createdSnapshot")
|
||||
updatedSnapshot Snapshot[] @relation("updatedSnapshot")
|
||||
createdUpdate Update[] @relation("createdUpdate")
|
||||
createdHistory SnapshotHistory[] @relation("createdHistory")
|
||||
createdAiJobs AiJobs[] @relation("createdAiJobs")
|
||||
// receive notifications
|
||||
notifications Notification[] @relation("user_notifications")
|
||||
settings UserSettings?
|
||||
comments Comment[]
|
||||
replies Reply[]
|
||||
commentAttachments CommentAttachment[] @relation("createdCommentAttachments")
|
||||
AccessToken AccessToken[]
|
||||
workspaceCalendars WorkspaceCalendar[]
|
||||
notifications Notification[] @relation("user_notifications")
|
||||
settings UserSettings?
|
||||
comments Comment[]
|
||||
replies Reply[]
|
||||
commentAttachments CommentAttachment[] @relation("createdCommentAttachments")
|
||||
AccessToken AccessToken[]
|
||||
workspaceCalendars WorkspaceCalendar[]
|
||||
workspaceMemberLastAccesses WorkspaceMemberLastAccess[]
|
||||
|
||||
@@index([email])
|
||||
@@map("users")
|
||||
@@ -151,6 +152,9 @@ model Workspace {
|
||||
workspaceCalendars WorkspaceCalendar[]
|
||||
workspaceAdminStats WorkspaceAdminStats[]
|
||||
workspaceAdminStatsDirties WorkspaceAdminStatsDirty[]
|
||||
workspaceAdminStatsDaily WorkspaceAdminStatsDaily[]
|
||||
workspaceDocViewDaily WorkspaceDocViewDaily[]
|
||||
workspaceMemberLastAccess WorkspaceMemberLastAccess[]
|
||||
|
||||
@@index([lastCheckEmbeddings])
|
||||
@@index([createdAt])
|
||||
@@ -180,6 +184,7 @@ model WorkspaceDoc {
|
||||
|
||||
@@id([workspaceId, docId])
|
||||
@@index([workspaceId, public])
|
||||
@@index([public, publishedAt])
|
||||
@@map("workspace_pages")
|
||||
}
|
||||
|
||||
@@ -320,6 +325,62 @@ model WorkspaceAdminStatsDirty {
|
||||
@@map("workspace_admin_stats_dirty")
|
||||
}
|
||||
|
||||
model WorkspaceAdminStatsDaily {
|
||||
workspaceId String @map("workspace_id") @db.VarChar
|
||||
date DateTime @db.Date
|
||||
snapshotSize BigInt @default(0) @map("snapshot_size") @db.BigInt
|
||||
blobSize BigInt @default(0) @map("blob_size") @db.BigInt
|
||||
memberCount BigInt @default(0) @map("member_count") @db.BigInt
|
||||
updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamptz(3)
|
||||
|
||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([workspaceId, date])
|
||||
@@index([date])
|
||||
@@map("workspace_admin_stats_daily")
|
||||
}
|
||||
|
||||
model SyncActiveUsersMinutely {
|
||||
minuteTs DateTime @id @map("minute_ts") @db.Timestamptz(3)
|
||||
activeUsers Int @default(0) @map("active_users") @db.Integer
|
||||
updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamptz(3)
|
||||
|
||||
@@map("sync_active_users_minutely")
|
||||
}
|
||||
|
||||
model WorkspaceDocViewDaily {
|
||||
workspaceId String @map("workspace_id") @db.VarChar
|
||||
docId String @map("doc_id") @db.VarChar
|
||||
date DateTime @db.Date
|
||||
totalViews BigInt @default(0) @map("total_views") @db.BigInt
|
||||
uniqueViews BigInt @default(0) @map("unique_views") @db.BigInt
|
||||
guestViews BigInt @default(0) @map("guest_views") @db.BigInt
|
||||
lastAccessedAt DateTime? @map("last_accessed_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamptz(3)
|
||||
|
||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([workspaceId, docId, date])
|
||||
@@index([workspaceId, date])
|
||||
@@map("workspace_doc_view_daily")
|
||||
}
|
||||
|
||||
model WorkspaceMemberLastAccess {
|
||||
workspaceId String @map("workspace_id") @db.VarChar
|
||||
userId String @map("user_id") @db.VarChar
|
||||
lastAccessedAt DateTime @map("last_accessed_at") @db.Timestamptz(3)
|
||||
lastDocId String? @map("last_doc_id") @db.VarChar
|
||||
updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamptz(3)
|
||||
|
||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([workspaceId, userId])
|
||||
@@index([workspaceId, lastAccessedAt(sort: Desc)])
|
||||
@@index([workspaceId, lastDocId])
|
||||
@@map("workspace_member_last_access")
|
||||
}
|
||||
|
||||
// the latest snapshot of each doc that we've seen
|
||||
// Snapshot + Updates are the latest state of the doc
|
||||
model Snapshot {
|
||||
@@ -456,6 +517,7 @@ model AiSessionMessage {
|
||||
session AiSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([sessionId])
|
||||
@@index([createdAt, role])
|
||||
@@map("ai_sessions_messages")
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,28 @@
|
||||
import { getCurrentUserQuery } from '@affine/graphql';
|
||||
|
||||
import { JobExecutor } from '../../../base/job/queue/executor';
|
||||
import { DatabaseDocReader, DocReader } from '../../../core/doc';
|
||||
import { createApp } from '../create-app';
|
||||
import { e2e } from '../test';
|
||||
|
||||
e2e('should init doc service', async t => {
|
||||
type TestFlavor = 'doc' | 'graphql' | 'sync' | 'renderer' | 'front';
|
||||
|
||||
const createFlavorApp = async (flavor: TestFlavor) => {
|
||||
// @ts-expect-error override
|
||||
globalThis.env.FLAVOR = 'doc';
|
||||
await using app = await createApp();
|
||||
globalThis.env.FLAVOR = flavor;
|
||||
return await createApp({
|
||||
tapModule(module) {
|
||||
module.overrideProvider(JobExecutor).useValue({
|
||||
onConfigInit: async () => {},
|
||||
onConfigChanged: async () => {},
|
||||
onModuleDestroy: async () => {},
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
e2e('should init doc service', async t => {
|
||||
await using app = await createFlavorApp('doc');
|
||||
|
||||
const res = await app.GET('/info').expect(200);
|
||||
t.is(res.body.flavor, 'doc');
|
||||
@@ -15,9 +31,7 @@ e2e('should init doc service', async t => {
|
||||
});
|
||||
|
||||
e2e('should init graphql service', async t => {
|
||||
// @ts-expect-error override
|
||||
globalThis.env.FLAVOR = 'graphql';
|
||||
await using app = await createApp();
|
||||
await using app = await createFlavorApp('graphql');
|
||||
|
||||
const res = await app.GET('/info').expect(200);
|
||||
|
||||
@@ -28,28 +42,25 @@ e2e('should init graphql service', async t => {
|
||||
});
|
||||
|
||||
e2e('should init sync service', async t => {
|
||||
// @ts-expect-error override
|
||||
globalThis.env.FLAVOR = 'sync';
|
||||
await using app = await createApp();
|
||||
await using app = await createFlavorApp('sync');
|
||||
|
||||
const res = await app.GET('/info').expect(200);
|
||||
t.is(res.body.flavor, 'sync');
|
||||
});
|
||||
|
||||
e2e('should init renderer service', async t => {
|
||||
// @ts-expect-error override
|
||||
globalThis.env.FLAVOR = 'renderer';
|
||||
await using app = await createApp();
|
||||
await using app = await createFlavorApp('renderer');
|
||||
|
||||
const res = await app.GET('/info').expect(200);
|
||||
t.is(res.body.flavor, 'renderer');
|
||||
});
|
||||
|
||||
e2e('should init front service', async t => {
|
||||
// @ts-expect-error override
|
||||
globalThis.env.FLAVOR = 'front';
|
||||
await using app = await createApp();
|
||||
await using app = await createFlavorApp('front');
|
||||
|
||||
const res = await app.GET('/info').expect(200);
|
||||
t.is(res.body.flavor, 'front');
|
||||
|
||||
const docReader = app.get(DocReader);
|
||||
t.true(docReader instanceof DatabaseDocReader);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,610 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
import { app, e2e, Mockers } from '../test';
|
||||
|
||||
async function gql(query: string, variables?: Record<string, unknown>) {
|
||||
const res = await app.POST('/graphql').send({ query, variables }).expect(200);
|
||||
return res.body as {
|
||||
data?: Record<string, any>;
|
||||
errors?: Array<{ message: string; extensions: Record<string, any> }>;
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureAnalyticsTables(db: PrismaClient) {
|
||||
await db.$executeRawUnsafe(`
|
||||
CREATE TABLE IF NOT EXISTS workspace_admin_stats_daily (
|
||||
workspace_id VARCHAR NOT NULL,
|
||||
date DATE NOT NULL,
|
||||
snapshot_size BIGINT NOT NULL DEFAULT 0,
|
||||
blob_size BIGINT NOT NULL DEFAULT 0,
|
||||
member_count BIGINT NOT NULL DEFAULT 0,
|
||||
updated_at TIMESTAMPTZ(3) NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (workspace_id, date)
|
||||
);
|
||||
`);
|
||||
|
||||
await db.$executeRawUnsafe(`
|
||||
CREATE TABLE IF NOT EXISTS sync_active_users_minutely (
|
||||
minute_ts TIMESTAMPTZ(3) NOT NULL PRIMARY KEY,
|
||||
active_users INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TIMESTAMPTZ(3) NOT NULL DEFAULT NOW()
|
||||
);
|
||||
`);
|
||||
|
||||
await db.$executeRawUnsafe(`
|
||||
CREATE TABLE IF NOT EXISTS workspace_doc_view_daily (
|
||||
workspace_id VARCHAR NOT NULL,
|
||||
doc_id VARCHAR NOT NULL,
|
||||
date DATE NOT NULL,
|
||||
total_views BIGINT NOT NULL DEFAULT 0,
|
||||
unique_views BIGINT NOT NULL DEFAULT 0,
|
||||
guest_views BIGINT NOT NULL DEFAULT 0,
|
||||
last_accessed_at TIMESTAMPTZ(3),
|
||||
updated_at TIMESTAMPTZ(3) NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (workspace_id, doc_id, date)
|
||||
);
|
||||
`);
|
||||
|
||||
await db.$executeRawUnsafe(`
|
||||
CREATE TABLE IF NOT EXISTS workspace_member_last_access (
|
||||
workspace_id VARCHAR NOT NULL,
|
||||
user_id VARCHAR NOT NULL,
|
||||
last_accessed_at TIMESTAMPTZ(3) NOT NULL,
|
||||
last_doc_id VARCHAR,
|
||||
updated_at TIMESTAMPTZ(3) NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (workspace_id, user_id)
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
async function createPublicDoc(input: {
|
||||
workspaceId: string;
|
||||
ownerId: string;
|
||||
title: string;
|
||||
updatedAt: Date;
|
||||
publishedAt: Date;
|
||||
}) {
|
||||
const snapshot = await app.create(Mockers.DocSnapshot, {
|
||||
workspaceId: input.workspaceId,
|
||||
user: { id: input.ownerId },
|
||||
});
|
||||
|
||||
await app.create(Mockers.DocMeta, {
|
||||
workspaceId: input.workspaceId,
|
||||
docId: snapshot.id,
|
||||
title: input.title,
|
||||
public: true,
|
||||
publishedAt: input.publishedAt,
|
||||
});
|
||||
|
||||
const db = app.get(PrismaClient);
|
||||
await db.snapshot.update({
|
||||
where: {
|
||||
workspaceId_id: {
|
||||
workspaceId: input.workspaceId,
|
||||
id: snapshot.id,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
updatedAt: input.updatedAt,
|
||||
updatedBy: input.ownerId,
|
||||
},
|
||||
});
|
||||
|
||||
return snapshot.id;
|
||||
}
|
||||
|
||||
e2e(
|
||||
'adminAllSharedLinks should support stable pagination and includeTotal',
|
||||
async t => {
|
||||
const admin = await app.create(Mockers.User, {
|
||||
feature: 'administrator',
|
||||
});
|
||||
await app.login(admin);
|
||||
|
||||
const owner = await app.create(Mockers.User);
|
||||
const workspace = await app.create(Mockers.Workspace, {
|
||||
owner: { id: owner.id },
|
||||
});
|
||||
|
||||
const newerDocId = await createPublicDoc({
|
||||
workspaceId: workspace.id,
|
||||
ownerId: owner.id,
|
||||
title: 'newer-doc',
|
||||
updatedAt: new Date('2026-02-11T10:00:00.000Z'),
|
||||
publishedAt: new Date('2026-02-11T10:00:00.000Z'),
|
||||
});
|
||||
const olderDocId = await createPublicDoc({
|
||||
workspaceId: workspace.id,
|
||||
ownerId: owner.id,
|
||||
title: 'older-doc',
|
||||
updatedAt: new Date('2026-02-10T10:00:00.000Z'),
|
||||
publishedAt: new Date('2026-02-10T10:00:00.000Z'),
|
||||
});
|
||||
|
||||
const db = app.get(PrismaClient);
|
||||
await ensureAnalyticsTables(db);
|
||||
await db.$executeRaw`
|
||||
INSERT INTO workspace_doc_view_daily (
|
||||
workspace_id, doc_id, date, total_views, unique_views, guest_views, last_accessed_at, updated_at
|
||||
)
|
||||
VALUES
|
||||
(${workspace.id}, ${newerDocId}, CURRENT_DATE, 10, 8, 2, NOW(), NOW()),
|
||||
(${workspace.id}, ${olderDocId}, CURRENT_DATE, 5, 4, 1, NOW(), NOW())
|
||||
ON CONFLICT (workspace_id, doc_id, date)
|
||||
DO UPDATE SET
|
||||
total_views = EXCLUDED.total_views,
|
||||
unique_views = EXCLUDED.unique_views,
|
||||
guest_views = EXCLUDED.guest_views,
|
||||
last_accessed_at = EXCLUDED.last_accessed_at,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
`;
|
||||
|
||||
const query = `
|
||||
query AdminAllSharedLinks($pagination: PaginationInput!, $filter: AdminAllSharedLinksFilterInput) {
|
||||
adminAllSharedLinks(pagination: $pagination, filter: $filter) {
|
||||
totalCount
|
||||
analyticsWindow {
|
||||
requestedSize
|
||||
effectiveSize
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
edges {
|
||||
cursor
|
||||
node {
|
||||
workspaceId
|
||||
docId
|
||||
title
|
||||
shareUrl
|
||||
views
|
||||
uniqueViews
|
||||
guestViews
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const firstPage = await gql(query, {
|
||||
pagination: { first: 1, offset: 0 },
|
||||
filter: {
|
||||
includeTotal: false,
|
||||
orderBy: 'UpdatedAtDesc',
|
||||
workspaceId: workspace.id,
|
||||
},
|
||||
});
|
||||
|
||||
t.falsy(firstPage.errors);
|
||||
const first = firstPage.data!.adminAllSharedLinks;
|
||||
t.is(first.totalCount, null);
|
||||
t.true(first.pageInfo.hasNextPage);
|
||||
t.is(first.edges.length, 1);
|
||||
t.true([newerDocId, olderDocId].includes(first.edges[0].node.docId));
|
||||
t.true(
|
||||
first.edges[0].node.shareUrl.includes(`/workspace/${workspace.id}/`)
|
||||
);
|
||||
|
||||
const secondPage = await gql(query, {
|
||||
pagination: { first: 1, offset: 0, after: first.pageInfo.endCursor },
|
||||
filter: {
|
||||
includeTotal: true,
|
||||
orderBy: 'UpdatedAtDesc',
|
||||
workspaceId: workspace.id,
|
||||
},
|
||||
});
|
||||
|
||||
t.falsy(secondPage.errors);
|
||||
const second = secondPage.data!.adminAllSharedLinks;
|
||||
t.is(second.totalCount, 2);
|
||||
t.is(second.edges.length, 1);
|
||||
t.not(second.edges[0].node.docId, first.edges[0].node.docId);
|
||||
|
||||
const conflict = await gql(query, {
|
||||
pagination: {
|
||||
first: 1,
|
||||
offset: 1,
|
||||
after: first.pageInfo.endCursor,
|
||||
},
|
||||
filter: {
|
||||
includeTotal: false,
|
||||
orderBy: 'UpdatedAtDesc',
|
||||
workspaceId: workspace.id,
|
||||
},
|
||||
});
|
||||
|
||||
t.truthy(conflict.errors?.length);
|
||||
t.is(conflict.errors![0].extensions.name, 'BAD_REQUEST');
|
||||
|
||||
const malformedDateCursor = await gql(query, {
|
||||
pagination: {
|
||||
first: 1,
|
||||
offset: 0,
|
||||
after: JSON.stringify({
|
||||
orderBy: 'UpdatedAtDesc',
|
||||
sortValue: 'not-a-date',
|
||||
workspaceId: workspace.id,
|
||||
docId: newerDocId,
|
||||
}),
|
||||
},
|
||||
filter: {
|
||||
includeTotal: false,
|
||||
orderBy: 'UpdatedAtDesc',
|
||||
workspaceId: workspace.id,
|
||||
},
|
||||
});
|
||||
|
||||
t.truthy(malformedDateCursor.errors?.length);
|
||||
t.is(malformedDateCursor.errors![0].extensions.name, 'BAD_REQUEST');
|
||||
|
||||
const malformedViewsCursor = await gql(query, {
|
||||
pagination: {
|
||||
first: 1,
|
||||
offset: 0,
|
||||
after: JSON.stringify({
|
||||
orderBy: 'ViewsDesc',
|
||||
sortValue: 'NaN',
|
||||
workspaceId: workspace.id,
|
||||
docId: newerDocId,
|
||||
}),
|
||||
},
|
||||
filter: {
|
||||
includeTotal: false,
|
||||
orderBy: 'ViewsDesc',
|
||||
workspaceId: workspace.id,
|
||||
},
|
||||
});
|
||||
|
||||
t.truthy(malformedViewsCursor.errors?.length);
|
||||
t.is(malformedViewsCursor.errors![0].extensions.name, 'BAD_REQUEST');
|
||||
}
|
||||
);
|
||||
|
||||
e2e(
|
||||
'adminDashboard should clamp window inputs and return expected buckets',
|
||||
async t => {
|
||||
const admin = await app.create(Mockers.User, {
|
||||
feature: 'administrator',
|
||||
});
|
||||
await app.login(admin);
|
||||
|
||||
const owner = await app.create(Mockers.User);
|
||||
const workspace = await app.create(Mockers.Workspace, {
|
||||
owner: { id: owner.id },
|
||||
});
|
||||
|
||||
const docId = await createPublicDoc({
|
||||
workspaceId: workspace.id,
|
||||
ownerId: owner.id,
|
||||
title: 'dashboard-doc',
|
||||
updatedAt: new Date(),
|
||||
publishedAt: new Date(),
|
||||
});
|
||||
|
||||
const db = app.get(PrismaClient);
|
||||
await ensureAnalyticsTables(db);
|
||||
const minute = new Date();
|
||||
minute.setSeconds(0, 0);
|
||||
|
||||
await db.$executeRaw`
|
||||
INSERT INTO sync_active_users_minutely (minute_ts, active_users, updated_at)
|
||||
VALUES (${minute}, 7, NOW())
|
||||
ON CONFLICT (minute_ts)
|
||||
DO UPDATE SET active_users = EXCLUDED.active_users, updated_at = EXCLUDED.updated_at
|
||||
`;
|
||||
|
||||
await db.$executeRaw`
|
||||
INSERT INTO workspace_admin_stats (
|
||||
workspace_id, snapshot_count, snapshot_size, blob_count, blob_size, member_count, public_page_count, features, updated_at
|
||||
)
|
||||
VALUES (${workspace.id}, 1, 100, 1, 50, 1, 1, ARRAY[]::text[], NOW())
|
||||
ON CONFLICT (workspace_id)
|
||||
DO UPDATE SET
|
||||
snapshot_count = EXCLUDED.snapshot_count,
|
||||
snapshot_size = EXCLUDED.snapshot_size,
|
||||
blob_count = EXCLUDED.blob_count,
|
||||
blob_size = EXCLUDED.blob_size,
|
||||
member_count = EXCLUDED.member_count,
|
||||
public_page_count = EXCLUDED.public_page_count,
|
||||
features = EXCLUDED.features,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
`;
|
||||
|
||||
await db.$executeRaw`
|
||||
INSERT INTO workspace_admin_stats_daily (
|
||||
workspace_id, date, snapshot_size, blob_size, member_count, updated_at
|
||||
)
|
||||
VALUES (${workspace.id}, CURRENT_DATE, 100, 50, 1, NOW())
|
||||
ON CONFLICT (workspace_id, date)
|
||||
DO UPDATE SET
|
||||
snapshot_size = EXCLUDED.snapshot_size,
|
||||
blob_size = EXCLUDED.blob_size,
|
||||
member_count = EXCLUDED.member_count,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
`;
|
||||
|
||||
await db.$executeRaw`
|
||||
INSERT INTO workspace_doc_view_daily (
|
||||
workspace_id, doc_id, date, total_views, unique_views, guest_views, last_accessed_at, updated_at
|
||||
)
|
||||
VALUES (${workspace.id}, ${docId}, CURRENT_DATE, 3, 2, 1, NOW(), NOW())
|
||||
ON CONFLICT (workspace_id, doc_id, date)
|
||||
DO UPDATE SET
|
||||
total_views = EXCLUDED.total_views,
|
||||
unique_views = EXCLUDED.unique_views,
|
||||
guest_views = EXCLUDED.guest_views,
|
||||
last_accessed_at = EXCLUDED.last_accessed_at,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
`;
|
||||
|
||||
const dashboardQuery = `
|
||||
query AdminDashboard($input: AdminDashboardInput) {
|
||||
adminDashboard(input: $input) {
|
||||
syncWindow {
|
||||
bucket
|
||||
requestedSize
|
||||
effectiveSize
|
||||
}
|
||||
storageWindow {
|
||||
bucket
|
||||
requestedSize
|
||||
effectiveSize
|
||||
}
|
||||
topSharedLinksWindow {
|
||||
bucket
|
||||
requestedSize
|
||||
effectiveSize
|
||||
}
|
||||
syncActiveUsersTimeline {
|
||||
minute
|
||||
activeUsers
|
||||
}
|
||||
workspaceStorageHistory {
|
||||
date
|
||||
value
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const result = await gql(dashboardQuery, {
|
||||
input: {
|
||||
storageHistoryDays: -10,
|
||||
syncHistoryHours: -10,
|
||||
sharedLinkWindowDays: -10,
|
||||
},
|
||||
});
|
||||
|
||||
t.falsy(result.errors);
|
||||
const dashboard = result.data!.adminDashboard;
|
||||
t.is(dashboard.syncWindow.bucket, 'Minute');
|
||||
t.is(dashboard.syncWindow.effectiveSize, 1);
|
||||
t.is(dashboard.storageWindow.bucket, 'Day');
|
||||
t.is(dashboard.storageWindow.effectiveSize, 1);
|
||||
t.is(dashboard.topSharedLinksWindow.effectiveSize, 1);
|
||||
t.is(dashboard.syncActiveUsersTimeline.length, 1);
|
||||
t.is(dashboard.workspaceStorageHistory.length, 1);
|
||||
}
|
||||
);
|
||||
|
||||
e2e(
|
||||
'Doc analytics and lastAccessedMembers should enforce permissions and privacy',
|
||||
async t => {
|
||||
const owner = await app.signup();
|
||||
const member = await app.create(Mockers.User);
|
||||
const staleMember = await app.create(Mockers.User);
|
||||
|
||||
const workspace = await app.create(Mockers.Workspace, {
|
||||
owner: { id: owner.id },
|
||||
});
|
||||
await app.create(Mockers.WorkspaceUser, {
|
||||
workspaceId: workspace.id,
|
||||
userId: member.id,
|
||||
});
|
||||
await app.create(Mockers.WorkspaceUser, {
|
||||
workspaceId: workspace.id,
|
||||
userId: staleMember.id,
|
||||
});
|
||||
|
||||
const docId = await createPublicDoc({
|
||||
workspaceId: workspace.id,
|
||||
ownerId: owner.id,
|
||||
title: 'page-analytics-doc',
|
||||
updatedAt: new Date(),
|
||||
publishedAt: new Date(),
|
||||
});
|
||||
|
||||
const db = app.get(PrismaClient);
|
||||
await ensureAnalyticsTables(db);
|
||||
await db.$executeRaw`
|
||||
INSERT INTO workspace_doc_view_daily (
|
||||
workspace_id, doc_id, date, total_views, unique_views, guest_views, last_accessed_at, updated_at
|
||||
)
|
||||
VALUES (${workspace.id}, ${docId}, CURRENT_DATE, 9, 5, 2, NOW(), NOW())
|
||||
ON CONFLICT (workspace_id, doc_id, date)
|
||||
DO UPDATE SET
|
||||
total_views = EXCLUDED.total_views,
|
||||
unique_views = EXCLUDED.unique_views,
|
||||
guest_views = EXCLUDED.guest_views,
|
||||
last_accessed_at = EXCLUDED.last_accessed_at,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
`;
|
||||
|
||||
await db.$executeRaw`
|
||||
INSERT INTO workspace_member_last_access (
|
||||
workspace_id, user_id, last_accessed_at, last_doc_id, updated_at
|
||||
)
|
||||
VALUES
|
||||
(${workspace.id}, ${owner.id}, NOW(), ${docId}, NOW()),
|
||||
(${workspace.id}, ${member.id}, NOW() - interval '1 minute', ${docId}, NOW()),
|
||||
(${workspace.id}, ${staleMember.id}, NOW() - interval '8 day', ${docId}, NOW())
|
||||
ON CONFLICT (workspace_id, user_id)
|
||||
DO UPDATE SET
|
||||
last_accessed_at = EXCLUDED.last_accessed_at,
|
||||
last_doc_id = EXCLUDED.last_doc_id,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
`;
|
||||
|
||||
const analyticsQuery = `
|
||||
query DocAnalytics($workspaceId: String!, $docId: String!) {
|
||||
workspace(id: $workspaceId) {
|
||||
doc(docId: $docId) {
|
||||
analytics(input: { windowDays: 999 }) {
|
||||
window {
|
||||
effectiveSize
|
||||
}
|
||||
series {
|
||||
date
|
||||
totalViews
|
||||
}
|
||||
summary {
|
||||
totalViews
|
||||
uniqueViews
|
||||
guestViews
|
||||
}
|
||||
}
|
||||
lastAccessedMembers(
|
||||
pagination: { first: 100, offset: 0 }
|
||||
includeTotal: true
|
||||
) {
|
||||
totalCount
|
||||
edges {
|
||||
node {
|
||||
user {
|
||||
id
|
||||
name
|
||||
avatarUrl
|
||||
}
|
||||
lastAccessedAt
|
||||
lastDocId
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
await app.login(owner);
|
||||
const ownerResult = await gql(analyticsQuery, {
|
||||
workspaceId: workspace.id,
|
||||
docId,
|
||||
});
|
||||
|
||||
t.falsy(ownerResult.errors);
|
||||
t.is(ownerResult.data!.workspace.doc.analytics.window.effectiveSize, 7);
|
||||
t.true(ownerResult.data!.workspace.doc.analytics.series.length > 0);
|
||||
t.is(ownerResult.data!.workspace.doc.lastAccessedMembers.totalCount, 2);
|
||||
t.is(ownerResult.data!.workspace.doc.lastAccessedMembers.edges.length, 2);
|
||||
t.false(
|
||||
ownerResult.data!.workspace.doc.lastAccessedMembers.edges.some(
|
||||
(edge: { node: { user: { id: string } } }) =>
|
||||
edge.node.user.id === staleMember.id
|
||||
)
|
||||
);
|
||||
|
||||
const malformedMembersCursor = await gql(
|
||||
`
|
||||
query DocMembersCursor($workspaceId: String!, $docId: String!, $after: String) {
|
||||
workspace(id: $workspaceId) {
|
||||
doc(docId: $docId) {
|
||||
lastAccessedMembers(
|
||||
pagination: { first: 10, offset: 0, after: $after }
|
||||
) {
|
||||
edges {
|
||||
node {
|
||||
user {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
workspaceId: workspace.id,
|
||||
docId,
|
||||
after: JSON.stringify({
|
||||
lastAccessedAt: 'not-a-date',
|
||||
userId: owner.id,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
t.truthy(malformedMembersCursor.errors?.length);
|
||||
t.is(malformedMembersCursor.errors![0].extensions.name, 'BAD_REQUEST');
|
||||
|
||||
const privacyQuery = `
|
||||
query DocMembersPrivacy($workspaceId: String!, $docId: String!) {
|
||||
workspace(id: $workspaceId) {
|
||||
doc(docId: $docId) {
|
||||
lastAccessedMembers(pagination: { first: 10, offset: 0 }) {
|
||||
edges {
|
||||
node {
|
||||
user {
|
||||
id
|
||||
email
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const privacyRes = await app
|
||||
.POST('/graphql')
|
||||
.send({
|
||||
query: privacyQuery,
|
||||
variables: {
|
||||
workspaceId: workspace.id,
|
||||
docId,
|
||||
},
|
||||
})
|
||||
.expect(400);
|
||||
const privacyResult = privacyRes.body as {
|
||||
errors?: Array<{ message: string }>;
|
||||
};
|
||||
t.truthy(privacyResult.errors?.length);
|
||||
t.true(
|
||||
privacyResult.errors![0].message.includes(
|
||||
'Cannot query field "email" on type "PublicUserType"'
|
||||
)
|
||||
);
|
||||
|
||||
await app.login(member);
|
||||
const memberDeniedRes = await app
|
||||
.POST('/graphql')
|
||||
.send({
|
||||
query: `
|
||||
query DocMembersDenied($workspaceId: String!, $docId: String!) {
|
||||
workspace(id: $workspaceId) {
|
||||
doc(docId: $docId) {
|
||||
lastAccessedMembers(pagination: { first: 10, offset: 0 }) {
|
||||
edges {
|
||||
node {
|
||||
user {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: { workspaceId: workspace.id, docId },
|
||||
})
|
||||
.expect(200);
|
||||
const memberDenied = memberDeniedRes.body as {
|
||||
errors?: Array<{ extensions: Record<string, unknown> }>;
|
||||
};
|
||||
t.truthy(memberDenied.errors?.length);
|
||||
t.is(memberDenied.errors![0].extensions.name, 'SPACE_ACCESS_DENIED');
|
||||
}
|
||||
);
|
||||
@@ -1,3 +1,4 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import test, { type ExecutionContext } from 'ava';
|
||||
import { io, type Socket as SocketIOClient } from 'socket.io-client';
|
||||
import { Doc, encodeStateAsUpdate } from 'yjs';
|
||||
@@ -146,6 +147,44 @@ function createYjsUpdateBase64() {
|
||||
return Buffer.from(update).toString('base64');
|
||||
}
|
||||
|
||||
async function ensureSyncActiveUsersTable(db: PrismaClient) {
|
||||
await db.$executeRawUnsafe(`
|
||||
CREATE TABLE IF NOT EXISTS sync_active_users_minutely (
|
||||
minute_ts TIMESTAMPTZ(3) NOT NULL PRIMARY KEY,
|
||||
active_users INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TIMESTAMPTZ(3) NOT NULL DEFAULT NOW()
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
async function latestActiveUsers(db: PrismaClient) {
|
||||
const rows = await db.$queryRaw<{ activeUsers: number }[]>`
|
||||
SELECT active_users::integer AS "activeUsers"
|
||||
FROM sync_active_users_minutely
|
||||
ORDER BY minute_ts DESC
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
if (!rows[0]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Number(rows[0].activeUsers);
|
||||
}
|
||||
|
||||
async function waitForActiveUsers(db: PrismaClient, expected: number) {
|
||||
const deadline = Date.now() + WS_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
const current = await latestActiveUsers(db);
|
||||
if (current === expected) {
|
||||
return;
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
throw new Error(`Timed out waiting active users=${expected}`);
|
||||
}
|
||||
|
||||
let app: TestingApp;
|
||||
let url: string;
|
||||
|
||||
@@ -461,3 +500,22 @@ test('space:join-awareness should reject clientVersion<0.25.0', async t => {
|
||||
socket.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
test('active users metric should dedupe multiple sockets for one user', async t => {
|
||||
const db = app.get(PrismaClient);
|
||||
await ensureSyncActiveUsersTable(db);
|
||||
|
||||
const { cookieHeader } = await login(app);
|
||||
const first = createClient(url, cookieHeader);
|
||||
const second = createClient(url, cookieHeader);
|
||||
|
||||
try {
|
||||
await Promise.all([waitForConnect(first), waitForConnect(second)]);
|
||||
await waitForActiveUsers(db, 1);
|
||||
t.pass();
|
||||
} finally {
|
||||
first.disconnect();
|
||||
second.disconnect();
|
||||
await Promise.all([waitForDisconnect(first), waitForDisconnect(second)]);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -217,6 +217,35 @@ test('should be able to get doc', async t => {
|
||||
t.deepEqual(res.body, Buffer.from([0, 0]));
|
||||
});
|
||||
|
||||
test('should record doc view when reading doc', async t => {
|
||||
const { app, workspace: doc, models } = t.context;
|
||||
|
||||
doc.getDoc.resolves({
|
||||
spaceId: '',
|
||||
docId: '',
|
||||
bin: Buffer.from([0, 0]),
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
const record = Sinon.stub(
|
||||
models.workspaceAnalytics,
|
||||
'recordDocView'
|
||||
).resolves();
|
||||
await app.login(t.context.u1);
|
||||
|
||||
const res = await app.GET('/api/workspaces/private/docs/public');
|
||||
t.is(res.status, HttpStatus.OK);
|
||||
t.true(record.calledOnce);
|
||||
t.like(record.firstCall.args[0], {
|
||||
workspaceId: 'private',
|
||||
docId: 'public',
|
||||
userId: t.context.u1.id,
|
||||
isGuest: false,
|
||||
});
|
||||
|
||||
record.restore();
|
||||
});
|
||||
|
||||
test('should be able to change page publish mode', async t => {
|
||||
const { app, workspace: doc, models } = t.context;
|
||||
|
||||
|
||||
@@ -159,8 +159,11 @@ export function buildAppModule(env: Env) {
|
||||
// basic
|
||||
.use(...FunctionalityModules)
|
||||
|
||||
// enable indexer module on graphql server and doc service
|
||||
.useIf(() => env.flavors.graphql || env.flavors.doc, IndexerModule)
|
||||
// enable indexer module on graphql, doc and front service
|
||||
.useIf(
|
||||
() => env.flavors.graphql || env.flavors.doc || env.flavors.front,
|
||||
IndexerModule
|
||||
)
|
||||
|
||||
// auth
|
||||
.use(UserModule, AuthModule, PermissionModule)
|
||||
@@ -202,8 +205,8 @@ export function buildAppModule(env: Env) {
|
||||
AccessTokenModule,
|
||||
QueueDashboardModule
|
||||
)
|
||||
// doc service only
|
||||
.useIf(() => env.flavors.doc, DocServiceModule)
|
||||
// doc service and front service
|
||||
.useIf(() => env.flavors.doc || env.flavors.front, DocServiceModule)
|
||||
// worker for and self-hosted API only for self-host and local development only
|
||||
.useIf(() => env.dev || env.selfhosted, WorkerModule, SelfhostModule)
|
||||
// static frontend routes for front flavor
|
||||
|
||||
@@ -82,7 +82,7 @@ test('should decode pagination input', async t => {
|
||||
await app.gql(query, {
|
||||
input: {
|
||||
first: 5,
|
||||
offset: 1,
|
||||
offset: 0,
|
||||
after: Buffer.from('4').toString('base64'),
|
||||
},
|
||||
});
|
||||
@@ -90,12 +90,34 @@ test('should decode pagination input', async t => {
|
||||
t.true(
|
||||
paginationStub.calledOnceWithExactly({
|
||||
first: 5,
|
||||
offset: 1,
|
||||
offset: 0,
|
||||
after: '4',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('should reject mixed pagination cursor and offset', async t => {
|
||||
const res = await app.POST('/graphql').send({
|
||||
query,
|
||||
variables: {
|
||||
input: {
|
||||
first: 5,
|
||||
offset: 1,
|
||||
after: Buffer.from('4').toString('base64'),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
t.is(res.status, 200);
|
||||
t.truthy(res.body.errors?.length);
|
||||
t.is(
|
||||
res.body.errors[0].message,
|
||||
'pagination.after and pagination.offset cannot be used together'
|
||||
);
|
||||
t.is(res.body.errors[0].extensions.status, 400);
|
||||
t.is(res.body.errors[0].extensions.name, 'BAD_REQUEST');
|
||||
});
|
||||
|
||||
test('should return encode pageInfo', async t => {
|
||||
const result = paginate(
|
||||
ITEMS.slice(10, 20),
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { PipeTransform, Type } from '@nestjs/common';
|
||||
import { Field, InputType, Int, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { BadRequest } from '../error';
|
||||
|
||||
@InputType()
|
||||
export class PaginationInput {
|
||||
/**
|
||||
@@ -13,11 +15,15 @@ export class PaginationInput {
|
||||
*/
|
||||
static decode: PipeTransform<PaginationInput, PaginationInput> = {
|
||||
transform: value => {
|
||||
return {
|
||||
const input = {
|
||||
...value,
|
||||
first: Math.min(Math.max(value?.first ?? 10, 1), 100),
|
||||
offset: Math.max(value?.offset ?? 0, 0),
|
||||
after: decode(value?.after),
|
||||
// before: decode(value.before),
|
||||
};
|
||||
assertPaginationInput(input);
|
||||
return input;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -51,6 +57,18 @@ export class PaginationInput {
|
||||
// before?: string | null;
|
||||
}
|
||||
|
||||
export function assertPaginationInput(paginationInput?: PaginationInput) {
|
||||
if (!paginationInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (paginationInput.after && paginationInput.offset > 0) {
|
||||
throw new BadRequest(
|
||||
'pagination.after and pagination.offset cannot be used together'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const encode = (input: unknown) => {
|
||||
let inputStr: string;
|
||||
if (input instanceof Date) {
|
||||
@@ -65,7 +83,7 @@ const encode = (input: unknown) => {
|
||||
const decode = (base64String?: string | null) =>
|
||||
base64String ? Buffer.from(base64String, 'base64').toString('utf-8') : null;
|
||||
|
||||
function encodeWithJson(input: unknown) {
|
||||
export function encodeWithJson(input: unknown) {
|
||||
return encode(JSON.stringify(input ?? null));
|
||||
}
|
||||
|
||||
|
||||
@@ -31,8 +31,8 @@ export class JobExecutor implements OnModuleDestroy {
|
||||
? difference(QUEUES, [Queue.DOC, Queue.INDEXER])
|
||||
: [];
|
||||
|
||||
// NOTE(@forehalo): only enable doc queue in doc service
|
||||
if (env.flavors.doc) {
|
||||
// Enable doc/indexer queues in both doc and front service.
|
||||
if (env.flavors.doc || env.flavors.front) {
|
||||
queues.push(Queue.DOC);
|
||||
// NOTE(@fengmk2): Once the index task cannot be processed in time, it needs to be separated from the doc service and deployed independently.
|
||||
queues.push(Queue.INDEXER);
|
||||
|
||||
@@ -37,12 +37,7 @@ function extractTokenFromHeader(authorization: string) {
|
||||
|
||||
@Injectable()
|
||||
export class AuthService implements OnApplicationBootstrap {
|
||||
readonly cookieOptions: CookieOptions = {
|
||||
sameSite: 'lax',
|
||||
httpOnly: true,
|
||||
path: '/',
|
||||
secure: this.config.server.https,
|
||||
};
|
||||
readonly cookieOptions: CookieOptions;
|
||||
static readonly sessionCookieName = 'affine_session';
|
||||
static readonly userCookieName = 'affine_user_id';
|
||||
static readonly csrfCookieName = 'affine_csrf_token';
|
||||
@@ -51,7 +46,14 @@ export class AuthService implements OnApplicationBootstrap {
|
||||
private readonly config: Config,
|
||||
private readonly models: Models,
|
||||
private readonly mailer: Mailer
|
||||
) {}
|
||||
) {
|
||||
this.cookieOptions = {
|
||||
sameSite: 'lax',
|
||||
httpOnly: true,
|
||||
path: '/',
|
||||
secure: this.config.server.https,
|
||||
};
|
||||
}
|
||||
|
||||
async onApplicationBootstrap() {
|
||||
if (env.dev) {
|
||||
|
||||
@@ -2,18 +2,20 @@ import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { User, Workspace } from '@prisma/client';
|
||||
import ava, { TestFn } from 'ava';
|
||||
import Sinon from 'sinon';
|
||||
import { Doc as YDoc } from 'yjs';
|
||||
|
||||
import { createTestingApp, type TestingApp } from '../../../__tests__/utils';
|
||||
import { ConfigFactory } from '../../../base';
|
||||
import { Flavor } from '../../../env';
|
||||
import { Models } from '../../../models';
|
||||
import { PgWorkspaceDocStorageAdapter } from '../../doc';
|
||||
import { DocReader, PgWorkspaceDocStorageAdapter } from '../../doc';
|
||||
|
||||
const test = ava as TestFn<{
|
||||
models: Models;
|
||||
app: TestingApp;
|
||||
adapter: PgWorkspaceDocStorageAdapter;
|
||||
docReader: DocReader;
|
||||
}>;
|
||||
|
||||
test.before(async t => {
|
||||
@@ -23,6 +25,7 @@ test.before(async t => {
|
||||
|
||||
t.context.models = app.get(Models);
|
||||
t.context.adapter = app.get(PgWorkspaceDocStorageAdapter);
|
||||
t.context.docReader = app.get(DocReader);
|
||||
t.context.app = app;
|
||||
});
|
||||
|
||||
@@ -68,3 +71,41 @@ test('should render page success', async t => {
|
||||
await app.GET(`/workspace/${workspace.id}/${docId}`).expect(200);
|
||||
t.pass();
|
||||
});
|
||||
|
||||
test('should record page view when rendering shared page', async t => {
|
||||
const docId = randomUUID();
|
||||
const { app, adapter, models, docReader } = t.context;
|
||||
|
||||
const doc = new YDoc();
|
||||
const text = doc.getText('content');
|
||||
const updates: Buffer[] = [];
|
||||
|
||||
doc.on('update', update => {
|
||||
updates.push(Buffer.from(update));
|
||||
});
|
||||
|
||||
text.insert(0, 'analytics');
|
||||
await adapter.pushDocUpdates(workspace.id, docId, updates, user.id);
|
||||
await models.doc.publish(workspace.id, docId);
|
||||
|
||||
const docContent = Sinon.stub(docReader, 'getDocContent').resolves({
|
||||
title: 'analytics-doc',
|
||||
summary: 'summary',
|
||||
});
|
||||
const record = Sinon.stub(
|
||||
models.workspaceAnalytics,
|
||||
'recordDocView'
|
||||
).resolves();
|
||||
|
||||
await app.GET(`/workspace/${workspace.id}/${docId}`).expect(200);
|
||||
|
||||
t.true(record.calledOnce);
|
||||
t.like(record.firstCall.args[0], {
|
||||
workspaceId: workspace.id,
|
||||
docId,
|
||||
isGuest: true,
|
||||
});
|
||||
|
||||
docContent.restore();
|
||||
record.restore();
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
@@ -5,7 +6,7 @@ import { Controller, Get, Logger, Req, Res } from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
import isMobile from 'is-mobile';
|
||||
|
||||
import { Config, metrics } from '../../base';
|
||||
import { Config, getRequestTrackerId, metrics } from '../../base';
|
||||
import { Models } from '../../models';
|
||||
import { htmlSanitize } from '../../native';
|
||||
import { Public } from '../auth';
|
||||
@@ -60,6 +61,13 @@ export class DocRendererController {
|
||||
);
|
||||
}
|
||||
|
||||
private buildVisitorId(req: Request, workspaceId: string, docId: string) {
|
||||
const tracker = getRequestTrackerId(req);
|
||||
return createHash('sha256')
|
||||
.update(`${workspaceId}:${docId}:${tracker}`)
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('/*path')
|
||||
async render(@Req() req: Request, @Res() res: Response) {
|
||||
@@ -83,6 +91,22 @@ export class DocRendererController {
|
||||
? await this.getWorkspaceContent(workspaceId)
|
||||
: await this.getPageContent(workspaceId, subPath);
|
||||
metrics.doc.counter('render').add(1);
|
||||
|
||||
if (opts && workspaceId !== subPath) {
|
||||
void this.models.workspaceAnalytics
|
||||
.recordDocView({
|
||||
workspaceId,
|
||||
docId: subPath,
|
||||
visitorId: this.buildVisitorId(req, workspaceId, subPath),
|
||||
isGuest: true,
|
||||
})
|
||||
.catch(error => {
|
||||
this.logger.warn(
|
||||
`Failed to record shared page view: ${workspaceId}/${subPath}`,
|
||||
error as Error
|
||||
);
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
this.logger.error('failed to render page', e);
|
||||
}
|
||||
|
||||
@@ -447,7 +447,7 @@ export class RpcDocReader extends DatabaseDocReader {
|
||||
export const DocReaderProvider: FactoryProvider = {
|
||||
provide: DocReader,
|
||||
useFactory: (ref: ModuleRef) => {
|
||||
if (env.flavors.doc) {
|
||||
if (env.flavors.doc || env.flavors.front) {
|
||||
return ref.create(DatabaseDocReader);
|
||||
}
|
||||
return ref.create(RpcDocReader);
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { applyDecorators, Logger, UseInterceptors } from '@nestjs/common';
|
||||
import {
|
||||
applyDecorators,
|
||||
Logger,
|
||||
OnModuleDestroy,
|
||||
OnModuleInit,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ConnectedSocket,
|
||||
MessageBody,
|
||||
@@ -8,6 +14,7 @@ import {
|
||||
WebSocketGateway,
|
||||
WebSocketServer,
|
||||
} from '@nestjs/websockets';
|
||||
import type { Request } from 'express';
|
||||
import { ClsInterceptor } from 'nestjs-cls';
|
||||
import semver from 'semver';
|
||||
import { type Server, Socket } from 'socket.io';
|
||||
@@ -71,6 +78,7 @@ const DOC_UPDATES_PROTOCOL_026 = new semver.Range('>=0.26.0-0', {
|
||||
});
|
||||
|
||||
type SyncProtocolRoomType = Extract<RoomType, 'sync-025' | 'sync-026'>;
|
||||
const SOCKET_PRESENCE_USER_ID_KEY = 'affinePresenceUserId';
|
||||
|
||||
function normalizeWsClientVersion(clientVersion: string): string | null {
|
||||
if (env.namespaces.canary) {
|
||||
@@ -190,7 +198,11 @@ interface UpdateAwarenessMessage {
|
||||
@WebSocketGateway()
|
||||
@UseInterceptors(ClsInterceptor)
|
||||
export class SpaceSyncGateway
|
||||
implements OnGatewayConnection, OnGatewayDisconnect
|
||||
implements
|
||||
OnGatewayConnection,
|
||||
OnGatewayDisconnect,
|
||||
OnModuleInit,
|
||||
OnModuleDestroy
|
||||
{
|
||||
protected logger = new Logger(SpaceSyncGateway.name);
|
||||
|
||||
@@ -198,6 +210,7 @@ export class SpaceSyncGateway
|
||||
private readonly server!: Server;
|
||||
|
||||
private connectionCount = 0;
|
||||
private flushTimer?: NodeJS.Timeout;
|
||||
|
||||
constructor(
|
||||
private readonly ac: AccessController,
|
||||
@@ -208,6 +221,22 @@ export class SpaceSyncGateway
|
||||
private readonly models: Models
|
||||
) {}
|
||||
|
||||
onModuleInit() {
|
||||
this.flushTimer = setInterval(() => {
|
||||
this.flushActiveUsersMinute().catch(error => {
|
||||
this.logger.warn('Failed to flush active users minute', error as Error);
|
||||
});
|
||||
}, 60_000);
|
||||
this.flushTimer.unref?.();
|
||||
}
|
||||
|
||||
onModuleDestroy() {
|
||||
if (this.flushTimer) {
|
||||
clearInterval(this.flushTimer);
|
||||
this.flushTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private encodeUpdates(updates: Uint8Array[]) {
|
||||
return updates.map(update => Buffer.from(update).toString('base64'));
|
||||
}
|
||||
@@ -269,18 +298,95 @@ export class SpaceSyncGateway
|
||||
setImmediate(() => client.disconnect());
|
||||
}
|
||||
|
||||
handleConnection() {
|
||||
handleConnection(client: Socket) {
|
||||
this.connectionCount++;
|
||||
this.logger.debug(`New connection, total: ${this.connectionCount}`);
|
||||
metrics.socketio.gauge('connections').record(this.connectionCount);
|
||||
this.attachPresenceUserId(client);
|
||||
this.flushActiveUsersMinute().catch(error => {
|
||||
this.logger.warn('Failed to flush active users minute', error as Error);
|
||||
});
|
||||
}
|
||||
|
||||
handleDisconnect() {
|
||||
this.connectionCount--;
|
||||
handleDisconnect(_client: Socket) {
|
||||
this.connectionCount = Math.max(0, this.connectionCount - 1);
|
||||
this.logger.debug(
|
||||
`Connection disconnected, total: ${this.connectionCount}`
|
||||
);
|
||||
metrics.socketio.gauge('connections').record(this.connectionCount);
|
||||
void this.flushActiveUsersMinute({
|
||||
aggregateAcrossCluster: false,
|
||||
}).catch(error => {
|
||||
this.logger.warn('Failed to flush active users minute', error as Error);
|
||||
});
|
||||
}
|
||||
|
||||
private attachPresenceUserId(client: Socket) {
|
||||
const request = client.request as Request;
|
||||
const userId = request.session?.user.id ?? request.token?.user.id;
|
||||
if (typeof userId !== 'string' || !userId) {
|
||||
this.logger.warn(
|
||||
`Unable to resolve authenticated user id for socket ${client.id}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
client.data[SOCKET_PRESENCE_USER_ID_KEY] = userId;
|
||||
}
|
||||
|
||||
private resolvePresenceUserId(socket: { data?: unknown }) {
|
||||
if (!socket.data || typeof socket.data !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const userId = (socket.data as Record<string, unknown>)[
|
||||
SOCKET_PRESENCE_USER_ID_KEY
|
||||
];
|
||||
return typeof userId === 'string' && userId ? userId : null;
|
||||
}
|
||||
|
||||
private async flushActiveUsersMinute(options?: {
|
||||
aggregateAcrossCluster?: boolean;
|
||||
}) {
|
||||
const minute = new Date();
|
||||
minute.setSeconds(0, 0);
|
||||
|
||||
const aggregateAcrossCluster = options?.aggregateAcrossCluster ?? true;
|
||||
let activeUsers = Math.max(0, this.connectionCount);
|
||||
if (aggregateAcrossCluster) {
|
||||
try {
|
||||
const sockets = await this.server.fetchSockets();
|
||||
const uniqueUsers = new Set<string>();
|
||||
let missingUserCount = 0;
|
||||
for (const socket of sockets) {
|
||||
const userId = this.resolvePresenceUserId(socket);
|
||||
if (userId) {
|
||||
uniqueUsers.add(userId);
|
||||
} else {
|
||||
missingUserCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (missingUserCount > 0) {
|
||||
activeUsers = sockets.length;
|
||||
this.logger.warn(
|
||||
`Unable to resolve user id for ${missingUserCount} active sockets, fallback to connection count`
|
||||
);
|
||||
} else {
|
||||
activeUsers = uniqueUsers.size;
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
'Failed to aggregate active users from sockets, using local value',
|
||||
error as Error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await this.models.workspaceAnalytics.upsertSyncActiveUsersMinute(
|
||||
minute,
|
||||
activeUsers
|
||||
);
|
||||
}
|
||||
|
||||
@OnEvent('doc.updates.pushed')
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import { Controller, Get, Logger, Param, Query, Res } from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Logger,
|
||||
Param,
|
||||
Query,
|
||||
Req,
|
||||
Res,
|
||||
} from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
import {
|
||||
applyAttachHeaders,
|
||||
@@ -8,6 +18,7 @@ import {
|
||||
CommentAttachmentNotFound,
|
||||
DocHistoryNotFound,
|
||||
DocNotFound,
|
||||
getRequestTrackerId,
|
||||
InvalidHistoryTimestamp,
|
||||
} from '../../base';
|
||||
import { DocMode, Models, PublicDocMode } from '../../models';
|
||||
@@ -30,6 +41,13 @@ export class WorkspacesController {
|
||||
private readonly models: Models
|
||||
) {}
|
||||
|
||||
private buildVisitorId(req: Request, workspaceId: string, docId: string) {
|
||||
const tracker = getRequestTrackerId(req);
|
||||
return createHash('sha256')
|
||||
.update(`${workspaceId}:${docId}:${tracker}`)
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
// get workspace blob
|
||||
//
|
||||
// NOTE: because graphql can't represent a File, so we have to use REST API to get blob
|
||||
@@ -99,6 +117,7 @@ export class WorkspacesController {
|
||||
@CallMetric('controllers', 'workspace_get_doc')
|
||||
async doc(
|
||||
@CurrentUser() user: CurrentUser | undefined,
|
||||
@Req() req: Request,
|
||||
@Param('id') ws: string,
|
||||
@Param('guid') guid: string,
|
||||
@Res() res: Response
|
||||
@@ -127,6 +146,23 @@ export class WorkspacesController {
|
||||
});
|
||||
}
|
||||
|
||||
if (!docId.isWorkspace) {
|
||||
void this.models.workspaceAnalytics
|
||||
.recordDocView({
|
||||
workspaceId: docId.workspace,
|
||||
docId: docId.guid,
|
||||
userId: user?.id,
|
||||
visitorId: this.buildVisitorId(req, docId.workspace, docId.guid),
|
||||
isGuest: !user,
|
||||
})
|
||||
.catch(error => {
|
||||
this.logger.warn(
|
||||
`Failed to record doc view: ${docId.workspace}/${docId.guid}`,
|
||||
error as Error
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (!docId.isWorkspace) {
|
||||
// fetch the publish page mode for publish page
|
||||
const docMeta = await this.models.doc.getMeta(
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
} from '@nestjs/graphql';
|
||||
import { SafeIntResolver } from 'graphql-scalars';
|
||||
|
||||
import { PaginationInput, URLHelper } from '../../../base';
|
||||
import { PageInfo } from '../../../base/graphql/pagination';
|
||||
import {
|
||||
Feature,
|
||||
Models,
|
||||
@@ -25,6 +27,7 @@ import {
|
||||
} from '../../../models';
|
||||
import { Admin } from '../../common';
|
||||
import { WorkspaceUserType } from '../../user';
|
||||
import { TimeWindow } from './analytics-types';
|
||||
|
||||
enum AdminWorkspaceSort {
|
||||
CreatedAt = 'CreatedAt',
|
||||
@@ -40,6 +43,16 @@ registerEnumType(AdminWorkspaceSort, {
|
||||
name: 'AdminWorkspaceSort',
|
||||
});
|
||||
|
||||
enum AdminSharedLinksOrder {
|
||||
UpdatedAtDesc = 'UpdatedAtDesc',
|
||||
PublishedAtDesc = 'PublishedAtDesc',
|
||||
ViewsDesc = 'ViewsDesc',
|
||||
}
|
||||
|
||||
registerEnumType(AdminSharedLinksOrder, {
|
||||
name: 'AdminSharedLinksOrder',
|
||||
});
|
||||
|
||||
@InputType()
|
||||
class ListWorkspaceInput {
|
||||
@Field(() => Int, { defaultValue: 20 })
|
||||
@@ -106,6 +119,195 @@ class AdminWorkspaceSharedLink {
|
||||
publishedAt?: Date | null;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
class AdminDashboardInput {
|
||||
@Field(() => String, { nullable: true, defaultValue: 'UTC' })
|
||||
timezone?: string;
|
||||
|
||||
@Field(() => Int, { nullable: true, defaultValue: 30 })
|
||||
storageHistoryDays?: number;
|
||||
|
||||
@Field(() => Int, { nullable: true, defaultValue: 48 })
|
||||
syncHistoryHours?: number;
|
||||
|
||||
@Field(() => Int, { nullable: true, defaultValue: 28 })
|
||||
sharedLinkWindowDays?: number;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
class AdminDashboardMinutePoint {
|
||||
@Field(() => Date)
|
||||
minute!: Date;
|
||||
|
||||
@Field(() => Int)
|
||||
activeUsers!: number;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
class AdminDashboardValueDayPoint {
|
||||
@Field(() => Date)
|
||||
date!: Date;
|
||||
|
||||
@Field(() => SafeIntResolver)
|
||||
value!: number;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
class AdminSharedLinkTopItem {
|
||||
@Field(() => String)
|
||||
workspaceId!: string;
|
||||
|
||||
@Field(() => String)
|
||||
docId!: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
title?: string | null;
|
||||
|
||||
@Field(() => String)
|
||||
shareUrl!: string;
|
||||
|
||||
@Field(() => Date, { nullable: true })
|
||||
publishedAt?: Date | null;
|
||||
|
||||
@Field(() => SafeIntResolver)
|
||||
views!: number;
|
||||
|
||||
@Field(() => SafeIntResolver)
|
||||
uniqueViews!: number;
|
||||
|
||||
@Field(() => SafeIntResolver)
|
||||
guestViews!: number;
|
||||
|
||||
@Field(() => Date, { nullable: true })
|
||||
lastAccessedAt?: Date | null;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
class AdminDashboard {
|
||||
@Field(() => Int)
|
||||
syncActiveUsers!: number;
|
||||
|
||||
@Field(() => [AdminDashboardMinutePoint])
|
||||
syncActiveUsersTimeline!: AdminDashboardMinutePoint[];
|
||||
|
||||
@Field(() => TimeWindow)
|
||||
syncWindow!: TimeWindow;
|
||||
|
||||
@Field(() => SafeIntResolver)
|
||||
copilotConversations!: number;
|
||||
|
||||
@Field(() => SafeIntResolver)
|
||||
workspaceStorageBytes!: number;
|
||||
|
||||
@Field(() => SafeIntResolver)
|
||||
blobStorageBytes!: number;
|
||||
|
||||
@Field(() => [AdminDashboardValueDayPoint])
|
||||
workspaceStorageHistory!: AdminDashboardValueDayPoint[];
|
||||
|
||||
@Field(() => [AdminDashboardValueDayPoint])
|
||||
blobStorageHistory!: AdminDashboardValueDayPoint[];
|
||||
|
||||
@Field(() => TimeWindow)
|
||||
storageWindow!: TimeWindow;
|
||||
|
||||
@Field(() => [AdminSharedLinkTopItem])
|
||||
topSharedLinks!: AdminSharedLinkTopItem[];
|
||||
|
||||
@Field(() => TimeWindow)
|
||||
topSharedLinksWindow!: TimeWindow;
|
||||
|
||||
@Field(() => Date)
|
||||
generatedAt!: Date;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
class AdminAllSharedLinksFilterInput {
|
||||
@Field(() => String, { nullable: true })
|
||||
keyword?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
workspaceId?: string;
|
||||
|
||||
@Field(() => Date, { nullable: true })
|
||||
updatedAfter?: Date;
|
||||
|
||||
@Field(() => AdminSharedLinksOrder, {
|
||||
nullable: true,
|
||||
defaultValue: AdminSharedLinksOrder.UpdatedAtDesc,
|
||||
})
|
||||
orderBy?: AdminSharedLinksOrder;
|
||||
|
||||
@Field(() => Int, { nullable: true, defaultValue: 28 })
|
||||
analyticsWindowDays?: number;
|
||||
|
||||
@Field(() => Boolean, { nullable: true, defaultValue: false })
|
||||
includeTotal?: boolean;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
class AdminAllSharedLink {
|
||||
@Field(() => String)
|
||||
workspaceId!: string;
|
||||
|
||||
@Field(() => String)
|
||||
docId!: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
title?: string | null;
|
||||
|
||||
@Field(() => Date, { nullable: true })
|
||||
publishedAt?: Date | null;
|
||||
|
||||
@Field(() => Date, { nullable: true })
|
||||
docUpdatedAt?: Date | null;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
workspaceOwnerId?: string | null;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
lastUpdaterId?: string | null;
|
||||
|
||||
@Field(() => String)
|
||||
shareUrl!: string;
|
||||
|
||||
@Field(() => SafeIntResolver, { nullable: true })
|
||||
views?: number | null;
|
||||
|
||||
@Field(() => SafeIntResolver, { nullable: true })
|
||||
uniqueViews?: number | null;
|
||||
|
||||
@Field(() => SafeIntResolver, { nullable: true })
|
||||
guestViews?: number | null;
|
||||
|
||||
@Field(() => Date, { nullable: true })
|
||||
lastAccessedAt?: Date | null;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
class AdminAllSharedLinkEdge {
|
||||
@Field(() => String)
|
||||
cursor!: string;
|
||||
|
||||
@Field(() => AdminAllSharedLink)
|
||||
node!: AdminAllSharedLink;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
class PaginatedAdminAllSharedLink {
|
||||
@Field(() => [AdminAllSharedLinkEdge])
|
||||
edges!: AdminAllSharedLinkEdge[];
|
||||
|
||||
@Field(() => PageInfo)
|
||||
pageInfo!: PageInfo;
|
||||
|
||||
@Field(() => Int, { nullable: true })
|
||||
totalCount?: number;
|
||||
|
||||
@Field(() => TimeWindow)
|
||||
analyticsWindow!: TimeWindow;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class AdminWorkspace {
|
||||
@Field()
|
||||
@@ -187,7 +389,10 @@ class AdminUpdateWorkspaceInput extends PartialType(
|
||||
@Admin()
|
||||
@Resolver(() => AdminWorkspace)
|
||||
export class AdminWorkspaceResolver {
|
||||
constructor(private readonly models: Models) {}
|
||||
constructor(
|
||||
private readonly models: Models,
|
||||
private readonly url: URLHelper
|
||||
) {}
|
||||
|
||||
private assertCloudOnly() {
|
||||
if (env.selfhosted) {
|
||||
@@ -261,6 +466,72 @@ export class AdminWorkspaceResolver {
|
||||
return row;
|
||||
}
|
||||
|
||||
@Query(() => AdminDashboard, {
|
||||
description: 'Get aggregated dashboard metrics for admin panel',
|
||||
})
|
||||
async adminDashboard(
|
||||
@Args('input', { nullable: true, type: () => AdminDashboardInput })
|
||||
input?: AdminDashboardInput
|
||||
) {
|
||||
this.assertCloudOnly();
|
||||
const dashboard = await this.models.workspaceAnalytics.adminGetDashboard({
|
||||
timezone: input?.timezone,
|
||||
storageHistoryDays: input?.storageHistoryDays,
|
||||
syncHistoryHours: input?.syncHistoryHours,
|
||||
sharedLinkWindowDays: input?.sharedLinkWindowDays,
|
||||
});
|
||||
|
||||
return {
|
||||
...dashboard,
|
||||
topSharedLinks: dashboard.topSharedLinks.map(link => ({
|
||||
...link,
|
||||
shareUrl: this.url.link(`/workspace/${link.workspaceId}/${link.docId}`),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@Query(() => PaginatedAdminAllSharedLink, {
|
||||
description: 'List all shared links across workspaces for admin panel',
|
||||
})
|
||||
async adminAllSharedLinks(
|
||||
@Args('pagination', PaginationInput.decode) pagination: PaginationInput,
|
||||
@Args('filter', {
|
||||
nullable: true,
|
||||
type: () => AdminAllSharedLinksFilterInput,
|
||||
})
|
||||
filter?: AdminAllSharedLinksFilterInput
|
||||
) {
|
||||
this.assertCloudOnly();
|
||||
const result =
|
||||
await this.models.workspaceAnalytics.adminPaginateAllSharedLinks({
|
||||
keyword: filter?.keyword,
|
||||
workspaceId: filter?.workspaceId,
|
||||
updatedAfter: filter?.updatedAfter,
|
||||
orderBy:
|
||||
filter?.orderBy === AdminSharedLinksOrder.PublishedAtDesc
|
||||
? 'PublishedAtDesc'
|
||||
: filter?.orderBy === AdminSharedLinksOrder.ViewsDesc
|
||||
? 'ViewsDesc'
|
||||
: 'UpdatedAtDesc',
|
||||
analyticsWindowDays: filter?.analyticsWindowDays,
|
||||
includeTotal: filter?.includeTotal,
|
||||
pagination,
|
||||
});
|
||||
|
||||
return {
|
||||
...result,
|
||||
edges: result.edges.map(edge => ({
|
||||
...edge,
|
||||
node: {
|
||||
...edge.node,
|
||||
shareUrl: this.url.link(
|
||||
`/workspace/${edge.node.workspaceId}/${edge.node.docId}`
|
||||
),
|
||||
},
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@ResolveField(() => [AdminWorkspaceMember], {
|
||||
description: 'Members of workspace',
|
||||
})
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Field, Int, ObjectType, registerEnumType } from '@nestjs/graphql';
|
||||
|
||||
export enum TimeBucket {
|
||||
Minute = 'Minute',
|
||||
Day = 'Day',
|
||||
}
|
||||
|
||||
registerEnumType(TimeBucket, {
|
||||
name: 'TimeBucket',
|
||||
});
|
||||
|
||||
@ObjectType()
|
||||
export class TimeWindow {
|
||||
@Field(() => Date)
|
||||
from!: Date;
|
||||
|
||||
@Field(() => Date)
|
||||
to!: Date;
|
||||
|
||||
@Field(() => String)
|
||||
timezone!: string;
|
||||
|
||||
@Field(() => TimeBucket)
|
||||
bucket!: TimeBucket;
|
||||
|
||||
@Field(() => Int)
|
||||
requestedSize!: number;
|
||||
|
||||
@Field(() => Int)
|
||||
effectiveSize!: number;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Args,
|
||||
Field,
|
||||
InputType,
|
||||
Int,
|
||||
Mutation,
|
||||
ObjectType,
|
||||
Parent,
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
Resolver,
|
||||
} from '@nestjs/graphql';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { SafeIntResolver } from 'graphql-scalars';
|
||||
|
||||
import {
|
||||
Cache,
|
||||
@@ -27,6 +29,7 @@ import {
|
||||
PaginationInput,
|
||||
registerObjectType,
|
||||
} from '../../../base';
|
||||
import { PageInfo } from '../../../base/graphql/pagination';
|
||||
import { Models, PublicDocMode } from '../../../models';
|
||||
import { CurrentUser } from '../../auth';
|
||||
import { Editor } from '../../doc';
|
||||
@@ -38,6 +41,7 @@ import {
|
||||
} from '../../permission';
|
||||
import { PublicUserType, WorkspaceUserType } from '../../user';
|
||||
import { WorkspaceType } from '../types';
|
||||
import { TimeBucket, TimeWindow } from './analytics-types';
|
||||
import {
|
||||
DotToUnderline,
|
||||
mapPermissionsToGraphqlPermissions,
|
||||
@@ -194,6 +198,93 @@ class WorkspaceDocMeta {
|
||||
updatedBy!: EditorType | null;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
class DocPageAnalyticsInput {
|
||||
@Field(() => Int, { nullable: true, defaultValue: 28 })
|
||||
windowDays?: number;
|
||||
|
||||
@Field(() => String, { nullable: true, defaultValue: 'UTC' })
|
||||
timezone?: string;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
class DocPageAnalyticsPoint {
|
||||
@Field(() => Date)
|
||||
date!: Date;
|
||||
|
||||
@Field(() => SafeIntResolver)
|
||||
totalViews!: number;
|
||||
|
||||
@Field(() => SafeIntResolver)
|
||||
uniqueViews!: number;
|
||||
|
||||
@Field(() => SafeIntResolver)
|
||||
guestViews!: number;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
class DocPageAnalyticsSummary {
|
||||
@Field(() => SafeIntResolver)
|
||||
totalViews!: number;
|
||||
|
||||
@Field(() => SafeIntResolver)
|
||||
uniqueViews!: number;
|
||||
|
||||
@Field(() => SafeIntResolver)
|
||||
guestViews!: number;
|
||||
|
||||
@Field(() => Date, { nullable: true })
|
||||
lastAccessedAt!: Date | null;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
class DocPageAnalytics {
|
||||
@Field(() => TimeWindow)
|
||||
window!: TimeWindow;
|
||||
|
||||
@Field(() => [DocPageAnalyticsPoint])
|
||||
series!: DocPageAnalyticsPoint[];
|
||||
|
||||
@Field(() => DocPageAnalyticsSummary)
|
||||
summary!: DocPageAnalyticsSummary;
|
||||
|
||||
@Field(() => Date)
|
||||
generatedAt!: Date;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
class DocMemberLastAccess {
|
||||
@Field(() => PublicUserType)
|
||||
user!: PublicUserType;
|
||||
|
||||
@Field(() => Date)
|
||||
lastAccessedAt!: Date;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
lastDocId!: string | null;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
class DocMemberLastAccessEdge {
|
||||
@Field(() => String)
|
||||
cursor!: string;
|
||||
|
||||
@Field(() => DocMemberLastAccess)
|
||||
node!: DocMemberLastAccess;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
class PaginatedDocMemberLastAccess {
|
||||
@Field(() => [DocMemberLastAccessEdge])
|
||||
edges!: DocMemberLastAccessEdge[];
|
||||
|
||||
@Field(() => PageInfo)
|
||||
pageInfo!: PageInfo;
|
||||
|
||||
@Field(() => Int, { nullable: true })
|
||||
totalCount?: number;
|
||||
}
|
||||
|
||||
@Resolver(() => WorkspaceType)
|
||||
export class WorkspaceDocResolver {
|
||||
private readonly logger = new Logger(WorkspaceDocResolver.name);
|
||||
@@ -464,6 +555,64 @@ export class DocResolver {
|
||||
updatedBy: metadata.updatedByUser || null,
|
||||
};
|
||||
}
|
||||
|
||||
@ResolveField(() => DocPageAnalytics, {
|
||||
description: 'Doc page analytics in a time window',
|
||||
complexity: 5,
|
||||
})
|
||||
async analytics(
|
||||
@CurrentUser() me: CurrentUser,
|
||||
@Parent() doc: DocType,
|
||||
@Args('input', { nullable: true, type: () => DocPageAnalyticsInput })
|
||||
input?: DocPageAnalyticsInput
|
||||
): Promise<DocPageAnalytics> {
|
||||
await this.ac.user(me.id).doc(doc).assert('Doc.Read');
|
||||
|
||||
const analytics = await this.models.workspaceAnalytics.getDocPageAnalytics({
|
||||
workspaceId: doc.workspaceId,
|
||||
docId: doc.docId,
|
||||
windowDays: input?.windowDays,
|
||||
timezone: input?.timezone,
|
||||
});
|
||||
|
||||
return {
|
||||
...analytics,
|
||||
window: {
|
||||
...analytics.window,
|
||||
bucket:
|
||||
analytics.window.bucket === 'Minute'
|
||||
? TimeBucket.Minute
|
||||
: TimeBucket.Day,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ResolveField(() => PaginatedDocMemberLastAccess, {
|
||||
description: 'Paginated last accessed members of the current doc',
|
||||
complexity: 5,
|
||||
})
|
||||
async lastAccessedMembers(
|
||||
@CurrentUser() me: CurrentUser,
|
||||
@Parent() doc: DocType,
|
||||
@Args('pagination', PaginationInput.decode) pagination: PaginationInput,
|
||||
@Args('query', { nullable: true }) query?: string,
|
||||
@Args('includeTotal', { nullable: true, defaultValue: false })
|
||||
includeTotal?: boolean
|
||||
): Promise<PaginatedDocMemberLastAccess> {
|
||||
await this.ac
|
||||
.user(me.id)
|
||||
.workspace(doc.workspaceId)
|
||||
.assert('Workspace.Users.Manage');
|
||||
|
||||
return this.models.workspaceAnalytics.paginateDocLastAccessedMembers({
|
||||
workspaceId: doc.workspaceId,
|
||||
docId: doc.docId,
|
||||
pagination,
|
||||
query,
|
||||
includeTotal: includeTotal ?? false,
|
||||
});
|
||||
}
|
||||
|
||||
@ResolveField(() => DocPermissions)
|
||||
async permissions(
|
||||
@CurrentUser() user: CurrentUser,
|
||||
|
||||
@@ -124,6 +124,21 @@ export class WorkspaceStatsJob {
|
||||
`Recalibrate admin stats for ${processed} workspace(s) (last sid ${lastSid})`
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const snapshotted = await this.withAdvisoryLock(async tx => {
|
||||
await this.writeDailySnapshot(tx);
|
||||
return true;
|
||||
});
|
||||
if (snapshotted) {
|
||||
this.logger.debug('Wrote daily workspace admin stats snapshot');
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
'Failed to write daily workspace admin stats snapshot',
|
||||
error as Error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async withAdvisoryLock<T>(
|
||||
@@ -304,4 +319,31 @@ export class WorkspaceStatsJob {
|
||||
LIMIT ${limit}
|
||||
`;
|
||||
}
|
||||
|
||||
private async writeDailySnapshot(tx: Prisma.TransactionClient) {
|
||||
await tx.$executeRaw`
|
||||
INSERT INTO workspace_admin_stats_daily (
|
||||
workspace_id,
|
||||
date,
|
||||
snapshot_size,
|
||||
blob_size,
|
||||
member_count,
|
||||
updated_at
|
||||
)
|
||||
SELECT
|
||||
workspace_id,
|
||||
CURRENT_DATE,
|
||||
snapshot_size,
|
||||
blob_size,
|
||||
member_count,
|
||||
NOW()
|
||||
FROM workspace_admin_stats
|
||||
ON CONFLICT (workspace_id, date)
|
||||
DO UPDATE SET
|
||||
snapshot_size = EXCLUDED.snapshot_size,
|
||||
blob_size = EXCLUDED.blob_size,
|
||||
member_count = EXCLUDED.member_count,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ import { UserFeatureModel } from './user-feature';
|
||||
import { UserSettingsModel } from './user-settings';
|
||||
import { VerificationTokenModel } from './verification-token';
|
||||
import { WorkspaceModel } from './workspace';
|
||||
import { WorkspaceAnalyticsModel } from './workspace-analytics';
|
||||
import { WorkspaceCalendarModel } from './workspace-calendar';
|
||||
import { WorkspaceFeatureModel } from './workspace-feature';
|
||||
import { WorkspaceUserModel } from './workspace-user';
|
||||
@@ -68,6 +69,7 @@ const MODELS = {
|
||||
calendarEvent: CalendarEventModel,
|
||||
calendarEventInstance: CalendarEventInstanceModel,
|
||||
workspaceCalendar: WorkspaceCalendarModel,
|
||||
workspaceAnalytics: WorkspaceAnalyticsModel,
|
||||
};
|
||||
|
||||
type ModelsType = {
|
||||
@@ -144,6 +146,7 @@ export * from './user-feature';
|
||||
export * from './user-settings';
|
||||
export * from './verification-token';
|
||||
export * from './workspace';
|
||||
export * from './workspace-analytics';
|
||||
export * from './workspace-calendar';
|
||||
export * from './workspace-feature';
|
||||
export * from './workspace-user';
|
||||
|
||||
1138
packages/backend/server/src/models/workspace-analytics.ts
Normal file
1138
packages/backend/server/src/models/workspace-analytics.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -59,11 +59,13 @@ export const CheckoutParams = z.object({
|
||||
});
|
||||
|
||||
export abstract class SubscriptionManager {
|
||||
protected readonly scheduleManager = new ScheduleManager(this.stripeProvider);
|
||||
protected readonly scheduleManager: ScheduleManager;
|
||||
constructor(
|
||||
protected readonly stripeProvider: StripeFactory,
|
||||
protected readonly db: PrismaClient
|
||||
) {}
|
||||
) {
|
||||
this.scheduleManager = new ScheduleManager(this.stripeProvider);
|
||||
}
|
||||
|
||||
get stripe() {
|
||||
return this.stripeProvider.stripe;
|
||||
|
||||
@@ -75,7 +75,7 @@ export { CheckoutParams };
|
||||
@Injectable()
|
||||
export class SubscriptionService {
|
||||
private readonly logger = new Logger(SubscriptionService.name);
|
||||
private readonly scheduleManager = new ScheduleManager(this.stripeProvider);
|
||||
private readonly scheduleManager: ScheduleManager;
|
||||
|
||||
constructor(
|
||||
private readonly stripeProvider: StripeFactory,
|
||||
@@ -85,7 +85,9 @@ export class SubscriptionService {
|
||||
private readonly userManager: UserSubscriptionManager,
|
||||
private readonly workspaceManager: WorkspaceSubscriptionManager,
|
||||
private readonly selfhostManager: SelfhostTeamSubscriptionManager
|
||||
) {}
|
||||
) {
|
||||
this.scheduleManager = new ScheduleManager(this.stripeProvider);
|
||||
}
|
||||
|
||||
get stripe() {
|
||||
return this.stripeProvider.stripe;
|
||||
|
||||
@@ -5,12 +5,14 @@ import { fixUrl, OriginRules } from './utils';
|
||||
|
||||
@Injectable()
|
||||
export class WorkerService {
|
||||
allowedOrigins: OriginRules = [...this.url.allowedOrigins];
|
||||
allowedOrigins: OriginRules;
|
||||
|
||||
constructor(
|
||||
private readonly config: Config,
|
||||
private readonly url: URLHelper
|
||||
) {}
|
||||
) {
|
||||
this.allowedOrigins = [...this.url.allowedOrigins];
|
||||
}
|
||||
|
||||
@OnEvent('config.init')
|
||||
onConfigInit() {
|
||||
|
||||
@@ -30,6 +30,85 @@ input AddContextFileInput {
|
||||
contextId: String!
|
||||
}
|
||||
|
||||
type AdminAllSharedLink {
|
||||
docId: String!
|
||||
docUpdatedAt: DateTime
|
||||
guestViews: SafeInt
|
||||
lastAccessedAt: DateTime
|
||||
lastUpdaterId: String
|
||||
publishedAt: DateTime
|
||||
shareUrl: String!
|
||||
title: String
|
||||
uniqueViews: SafeInt
|
||||
views: SafeInt
|
||||
workspaceId: String!
|
||||
workspaceOwnerId: String
|
||||
}
|
||||
|
||||
type AdminAllSharedLinkEdge {
|
||||
cursor: String!
|
||||
node: AdminAllSharedLink!
|
||||
}
|
||||
|
||||
input AdminAllSharedLinksFilterInput {
|
||||
analyticsWindowDays: Int = 28
|
||||
includeTotal: Boolean = false
|
||||
keyword: String
|
||||
orderBy: AdminSharedLinksOrder = UpdatedAtDesc
|
||||
updatedAfter: DateTime
|
||||
workspaceId: String
|
||||
}
|
||||
|
||||
type AdminDashboard {
|
||||
blobStorageBytes: SafeInt!
|
||||
blobStorageHistory: [AdminDashboardValueDayPoint!]!
|
||||
copilotConversations: SafeInt!
|
||||
generatedAt: DateTime!
|
||||
storageWindow: TimeWindow!
|
||||
syncActiveUsers: Int!
|
||||
syncActiveUsersTimeline: [AdminDashboardMinutePoint!]!
|
||||
syncWindow: TimeWindow!
|
||||
topSharedLinks: [AdminSharedLinkTopItem!]!
|
||||
topSharedLinksWindow: TimeWindow!
|
||||
workspaceStorageBytes: SafeInt!
|
||||
workspaceStorageHistory: [AdminDashboardValueDayPoint!]!
|
||||
}
|
||||
|
||||
input AdminDashboardInput {
|
||||
sharedLinkWindowDays: Int = 28
|
||||
storageHistoryDays: Int = 30
|
||||
syncHistoryHours: Int = 48
|
||||
timezone: String = "UTC"
|
||||
}
|
||||
|
||||
type AdminDashboardMinutePoint {
|
||||
activeUsers: Int!
|
||||
minute: DateTime!
|
||||
}
|
||||
|
||||
type AdminDashboardValueDayPoint {
|
||||
date: DateTime!
|
||||
value: SafeInt!
|
||||
}
|
||||
|
||||
type AdminSharedLinkTopItem {
|
||||
docId: String!
|
||||
guestViews: SafeInt!
|
||||
lastAccessedAt: DateTime
|
||||
publishedAt: DateTime
|
||||
shareUrl: String!
|
||||
title: String
|
||||
uniqueViews: SafeInt!
|
||||
views: SafeInt!
|
||||
workspaceId: String!
|
||||
}
|
||||
|
||||
enum AdminSharedLinksOrder {
|
||||
PublishedAtDesc
|
||||
UpdatedAtDesc
|
||||
ViewsDesc
|
||||
}
|
||||
|
||||
input AdminUpdateWorkspaceInput {
|
||||
avatarKey: String
|
||||
enableAi: Boolean
|
||||
@@ -720,6 +799,17 @@ type DocHistoryType {
|
||||
workspaceId: String!
|
||||
}
|
||||
|
||||
type DocMemberLastAccess {
|
||||
lastAccessedAt: DateTime!
|
||||
lastDocId: String
|
||||
user: PublicUserType!
|
||||
}
|
||||
|
||||
type DocMemberLastAccessEdge {
|
||||
cursor: String!
|
||||
node: DocMemberLastAccess!
|
||||
}
|
||||
|
||||
"""Doc mode"""
|
||||
enum DocMode {
|
||||
edgeless
|
||||
@@ -731,6 +821,32 @@ type DocNotFoundDataType {
|
||||
spaceId: String!
|
||||
}
|
||||
|
||||
type DocPageAnalytics {
|
||||
generatedAt: DateTime!
|
||||
series: [DocPageAnalyticsPoint!]!
|
||||
summary: DocPageAnalyticsSummary!
|
||||
window: TimeWindow!
|
||||
}
|
||||
|
||||
input DocPageAnalyticsInput {
|
||||
timezone: String = "UTC"
|
||||
windowDays: Int = 28
|
||||
}
|
||||
|
||||
type DocPageAnalyticsPoint {
|
||||
date: DateTime!
|
||||
guestViews: SafeInt!
|
||||
totalViews: SafeInt!
|
||||
uniqueViews: SafeInt!
|
||||
}
|
||||
|
||||
type DocPageAnalyticsSummary {
|
||||
guestViews: SafeInt!
|
||||
lastAccessedAt: DateTime
|
||||
totalViews: SafeInt!
|
||||
uniqueViews: SafeInt!
|
||||
}
|
||||
|
||||
type DocPermissions {
|
||||
Doc_Comments_Create: Boolean!
|
||||
Doc_Comments_Delete: Boolean!
|
||||
@@ -763,6 +879,8 @@ enum DocRole {
|
||||
}
|
||||
|
||||
type DocType {
|
||||
"""Doc page analytics in a time window"""
|
||||
analytics(input: DocPageAnalyticsInput): DocPageAnalytics!
|
||||
createdAt: DateTime
|
||||
|
||||
"""Doc create user"""
|
||||
@@ -774,6 +892,9 @@ type DocType {
|
||||
grantedUsersList(pagination: PaginationInput!): PaginatedGrantedDocUserType!
|
||||
id: String!
|
||||
|
||||
"""Paginated last accessed members of the current doc"""
|
||||
lastAccessedMembers(includeTotal: Boolean = false, pagination: PaginationInput!, query: String): PaginatedDocMemberLastAccess!
|
||||
|
||||
"""Doc last updated user"""
|
||||
lastUpdatedBy: PublicUserType
|
||||
lastUpdaterId: String
|
||||
@@ -1677,6 +1798,13 @@ type PageInfo {
|
||||
startCursor: String
|
||||
}
|
||||
|
||||
type PaginatedAdminAllSharedLink {
|
||||
analyticsWindow: TimeWindow!
|
||||
edges: [AdminAllSharedLinkEdge!]!
|
||||
pageInfo: PageInfo!
|
||||
totalCount: Int
|
||||
}
|
||||
|
||||
type PaginatedCommentChangeObjectType {
|
||||
edges: [CommentChangeObjectTypeEdge!]!
|
||||
pageInfo: PageInfo!
|
||||
@@ -1701,6 +1829,12 @@ type PaginatedCopilotWorkspaceFileType {
|
||||
totalCount: Int!
|
||||
}
|
||||
|
||||
type PaginatedDocMemberLastAccess {
|
||||
edges: [DocMemberLastAccessEdge!]!
|
||||
pageInfo: PageInfo!
|
||||
totalCount: Int
|
||||
}
|
||||
|
||||
type PaginatedDocType {
|
||||
edges: [DocTypeEdge!]!
|
||||
pageInfo: PageInfo!
|
||||
@@ -1762,6 +1896,12 @@ type PublicUserType {
|
||||
}
|
||||
|
||||
type Query {
|
||||
"""List all shared links across workspaces for admin panel"""
|
||||
adminAllSharedLinks(filter: AdminAllSharedLinksFilterInput, pagination: PaginationInput!): PaginatedAdminAllSharedLink!
|
||||
|
||||
"""Get aggregated dashboard metrics for admin panel"""
|
||||
adminDashboard(input: AdminDashboardInput): AdminDashboard!
|
||||
|
||||
"""Get workspace detail for admin"""
|
||||
adminWorkspace(id: String!): AdminWorkspace
|
||||
|
||||
@@ -2207,6 +2347,20 @@ enum SubscriptionVariant {
|
||||
Onetime
|
||||
}
|
||||
|
||||
enum TimeBucket {
|
||||
Day
|
||||
Minute
|
||||
}
|
||||
|
||||
type TimeWindow {
|
||||
bucket: TimeBucket!
|
||||
effectiveSize: Int!
|
||||
from: DateTime!
|
||||
requestedSize: Int!
|
||||
timezone: String!
|
||||
to: DateTime!
|
||||
}
|
||||
|
||||
type TranscriptionItemType {
|
||||
end: String!
|
||||
speaker: String!
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
query adminAllSharedLinks(
|
||||
$pagination: PaginationInput!
|
||||
$filter: AdminAllSharedLinksFilterInput
|
||||
) {
|
||||
adminAllSharedLinks(pagination: $pagination, filter: $filter) {
|
||||
totalCount
|
||||
analyticsWindow {
|
||||
from
|
||||
to
|
||||
timezone
|
||||
bucket
|
||||
requestedSize
|
||||
effectiveSize
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
hasPreviousPage
|
||||
startCursor
|
||||
endCursor
|
||||
}
|
||||
edges {
|
||||
cursor
|
||||
node {
|
||||
workspaceId
|
||||
docId
|
||||
title
|
||||
publishedAt
|
||||
docUpdatedAt
|
||||
workspaceOwnerId
|
||||
lastUpdaterId
|
||||
shareUrl
|
||||
views
|
||||
uniqueViews
|
||||
guestViews
|
||||
lastAccessedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
query adminDashboard($input: AdminDashboardInput) {
|
||||
adminDashboard(input: $input) {
|
||||
syncActiveUsers
|
||||
syncActiveUsersTimeline {
|
||||
minute
|
||||
activeUsers
|
||||
}
|
||||
syncWindow {
|
||||
from
|
||||
to
|
||||
timezone
|
||||
bucket
|
||||
requestedSize
|
||||
effectiveSize
|
||||
}
|
||||
copilotConversations
|
||||
workspaceStorageBytes
|
||||
blobStorageBytes
|
||||
workspaceStorageHistory {
|
||||
date
|
||||
value
|
||||
}
|
||||
blobStorageHistory {
|
||||
date
|
||||
value
|
||||
}
|
||||
storageWindow {
|
||||
from
|
||||
to
|
||||
timezone
|
||||
bucket
|
||||
requestedSize
|
||||
effectiveSize
|
||||
}
|
||||
topSharedLinks {
|
||||
workspaceId
|
||||
docId
|
||||
title
|
||||
shareUrl
|
||||
publishedAt
|
||||
views
|
||||
uniqueViews
|
||||
guestViews
|
||||
lastAccessedAt
|
||||
}
|
||||
topSharedLinksWindow {
|
||||
from
|
||||
to
|
||||
timezone
|
||||
bucket
|
||||
requestedSize
|
||||
effectiveSize
|
||||
}
|
||||
generatedAt
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
query getDocLastAccessedMembers(
|
||||
$workspaceId: String!
|
||||
$docId: String!
|
||||
$pagination: PaginationInput!
|
||||
$query: String
|
||||
$includeTotal: Boolean
|
||||
) {
|
||||
workspace(id: $workspaceId) {
|
||||
doc(docId: $docId) {
|
||||
lastAccessedMembers(
|
||||
pagination: $pagination
|
||||
query: $query
|
||||
includeTotal: $includeTotal
|
||||
) {
|
||||
totalCount
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
hasPreviousPage
|
||||
startCursor
|
||||
endCursor
|
||||
}
|
||||
edges {
|
||||
cursor
|
||||
node {
|
||||
user {
|
||||
id
|
||||
name
|
||||
avatarUrl
|
||||
}
|
||||
lastAccessedAt
|
||||
lastDocId
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
query getDocPageAnalytics(
|
||||
$workspaceId: String!
|
||||
$docId: String!
|
||||
$input: DocPageAnalyticsInput
|
||||
) {
|
||||
workspace(id: $workspaceId) {
|
||||
doc(docId: $docId) {
|
||||
analytics(input: $input) {
|
||||
window {
|
||||
from
|
||||
to
|
||||
timezone
|
||||
bucket
|
||||
requestedSize
|
||||
effectiveSize
|
||||
}
|
||||
series {
|
||||
date
|
||||
totalViews
|
||||
uniqueViews
|
||||
guestViews
|
||||
}
|
||||
summary {
|
||||
totalViews
|
||||
uniqueViews
|
||||
guestViews
|
||||
lastAccessedAt
|
||||
}
|
||||
generatedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -144,6 +144,108 @@ export const revokeUserAccessTokenMutation = {
|
||||
}`,
|
||||
};
|
||||
|
||||
export const adminAllSharedLinksQuery = {
|
||||
id: 'adminAllSharedLinksQuery' as const,
|
||||
op: 'adminAllSharedLinks',
|
||||
query: `query adminAllSharedLinks($pagination: PaginationInput!, $filter: AdminAllSharedLinksFilterInput) {
|
||||
adminAllSharedLinks(pagination: $pagination, filter: $filter) {
|
||||
totalCount
|
||||
analyticsWindow {
|
||||
from
|
||||
to
|
||||
timezone
|
||||
bucket
|
||||
requestedSize
|
||||
effectiveSize
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
hasPreviousPage
|
||||
startCursor
|
||||
endCursor
|
||||
}
|
||||
edges {
|
||||
cursor
|
||||
node {
|
||||
workspaceId
|
||||
docId
|
||||
title
|
||||
publishedAt
|
||||
docUpdatedAt
|
||||
workspaceOwnerId
|
||||
lastUpdaterId
|
||||
shareUrl
|
||||
views
|
||||
uniqueViews
|
||||
guestViews
|
||||
lastAccessedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
};
|
||||
|
||||
export const adminDashboardQuery = {
|
||||
id: 'adminDashboardQuery' as const,
|
||||
op: 'adminDashboard',
|
||||
query: `query adminDashboard($input: AdminDashboardInput) {
|
||||
adminDashboard(input: $input) {
|
||||
syncActiveUsers
|
||||
syncActiveUsersTimeline {
|
||||
minute
|
||||
activeUsers
|
||||
}
|
||||
syncWindow {
|
||||
from
|
||||
to
|
||||
timezone
|
||||
bucket
|
||||
requestedSize
|
||||
effectiveSize
|
||||
}
|
||||
copilotConversations
|
||||
workspaceStorageBytes
|
||||
blobStorageBytes
|
||||
workspaceStorageHistory {
|
||||
date
|
||||
value
|
||||
}
|
||||
blobStorageHistory {
|
||||
date
|
||||
value
|
||||
}
|
||||
storageWindow {
|
||||
from
|
||||
to
|
||||
timezone
|
||||
bucket
|
||||
requestedSize
|
||||
effectiveSize
|
||||
}
|
||||
topSharedLinks {
|
||||
workspaceId
|
||||
docId
|
||||
title
|
||||
shareUrl
|
||||
publishedAt
|
||||
views
|
||||
uniqueViews
|
||||
guestViews
|
||||
lastAccessedAt
|
||||
}
|
||||
topSharedLinksWindow {
|
||||
from
|
||||
to
|
||||
timezone
|
||||
bucket
|
||||
requestedSize
|
||||
effectiveSize
|
||||
}
|
||||
generatedAt
|
||||
}
|
||||
}`,
|
||||
};
|
||||
|
||||
export const adminServerConfigQuery = {
|
||||
id: 'adminServerConfigQuery' as const,
|
||||
op: 'adminServerConfig',
|
||||
@@ -1877,6 +1979,76 @@ export const getDocDefaultRoleQuery = {
|
||||
}`,
|
||||
};
|
||||
|
||||
export const getDocLastAccessedMembersQuery = {
|
||||
id: 'getDocLastAccessedMembersQuery' as const,
|
||||
op: 'getDocLastAccessedMembers',
|
||||
query: `query getDocLastAccessedMembers($workspaceId: String!, $docId: String!, $pagination: PaginationInput!, $query: String, $includeTotal: Boolean) {
|
||||
workspace(id: $workspaceId) {
|
||||
doc(docId: $docId) {
|
||||
lastAccessedMembers(
|
||||
pagination: $pagination
|
||||
query: $query
|
||||
includeTotal: $includeTotal
|
||||
) {
|
||||
totalCount
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
hasPreviousPage
|
||||
startCursor
|
||||
endCursor
|
||||
}
|
||||
edges {
|
||||
cursor
|
||||
node {
|
||||
user {
|
||||
id
|
||||
name
|
||||
avatarUrl
|
||||
}
|
||||
lastAccessedAt
|
||||
lastDocId
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
};
|
||||
|
||||
export const getDocPageAnalyticsQuery = {
|
||||
id: 'getDocPageAnalyticsQuery' as const,
|
||||
op: 'getDocPageAnalytics',
|
||||
query: `query getDocPageAnalytics($workspaceId: String!, $docId: String!, $input: DocPageAnalyticsInput) {
|
||||
workspace(id: $workspaceId) {
|
||||
doc(docId: $docId) {
|
||||
analytics(input: $input) {
|
||||
window {
|
||||
from
|
||||
to
|
||||
timezone
|
||||
bucket
|
||||
requestedSize
|
||||
effectiveSize
|
||||
}
|
||||
series {
|
||||
date
|
||||
totalViews
|
||||
uniqueViews
|
||||
guestViews
|
||||
}
|
||||
summary {
|
||||
totalViews
|
||||
uniqueViews
|
||||
guestViews
|
||||
lastAccessedAt
|
||||
}
|
||||
generatedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
};
|
||||
|
||||
export const getDocSummaryQuery = {
|
||||
id: 'getDocSummaryQuery' as const,
|
||||
op: 'getDocSummary',
|
||||
|
||||
@@ -66,6 +66,91 @@ export interface AddContextFileInput {
|
||||
contextId: Scalars['String']['input'];
|
||||
}
|
||||
|
||||
export interface AdminAllSharedLink {
|
||||
__typename?: 'AdminAllSharedLink';
|
||||
docId: Scalars['String']['output'];
|
||||
docUpdatedAt: Maybe<Scalars['DateTime']['output']>;
|
||||
guestViews: Maybe<Scalars['SafeInt']['output']>;
|
||||
lastAccessedAt: Maybe<Scalars['DateTime']['output']>;
|
||||
lastUpdaterId: Maybe<Scalars['String']['output']>;
|
||||
publishedAt: Maybe<Scalars['DateTime']['output']>;
|
||||
shareUrl: Scalars['String']['output'];
|
||||
title: Maybe<Scalars['String']['output']>;
|
||||
uniqueViews: Maybe<Scalars['SafeInt']['output']>;
|
||||
views: Maybe<Scalars['SafeInt']['output']>;
|
||||
workspaceId: Scalars['String']['output'];
|
||||
workspaceOwnerId: Maybe<Scalars['String']['output']>;
|
||||
}
|
||||
|
||||
export interface AdminAllSharedLinkEdge {
|
||||
__typename?: 'AdminAllSharedLinkEdge';
|
||||
cursor: Scalars['String']['output'];
|
||||
node: AdminAllSharedLink;
|
||||
}
|
||||
|
||||
export interface AdminAllSharedLinksFilterInput {
|
||||
analyticsWindowDays?: InputMaybe<Scalars['Int']['input']>;
|
||||
includeTotal?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
keyword?: InputMaybe<Scalars['String']['input']>;
|
||||
orderBy?: InputMaybe<AdminSharedLinksOrder>;
|
||||
updatedAfter?: InputMaybe<Scalars['DateTime']['input']>;
|
||||
workspaceId?: InputMaybe<Scalars['String']['input']>;
|
||||
}
|
||||
|
||||
export interface AdminDashboard {
|
||||
__typename?: 'AdminDashboard';
|
||||
blobStorageBytes: Scalars['SafeInt']['output'];
|
||||
blobStorageHistory: Array<AdminDashboardValueDayPoint>;
|
||||
copilotConversations: Scalars['SafeInt']['output'];
|
||||
generatedAt: Scalars['DateTime']['output'];
|
||||
storageWindow: TimeWindow;
|
||||
syncActiveUsers: Scalars['Int']['output'];
|
||||
syncActiveUsersTimeline: Array<AdminDashboardMinutePoint>;
|
||||
syncWindow: TimeWindow;
|
||||
topSharedLinks: Array<AdminSharedLinkTopItem>;
|
||||
topSharedLinksWindow: TimeWindow;
|
||||
workspaceStorageBytes: Scalars['SafeInt']['output'];
|
||||
workspaceStorageHistory: Array<AdminDashboardValueDayPoint>;
|
||||
}
|
||||
|
||||
export interface AdminDashboardInput {
|
||||
sharedLinkWindowDays?: InputMaybe<Scalars['Int']['input']>;
|
||||
storageHistoryDays?: InputMaybe<Scalars['Int']['input']>;
|
||||
syncHistoryHours?: InputMaybe<Scalars['Int']['input']>;
|
||||
timezone?: InputMaybe<Scalars['String']['input']>;
|
||||
}
|
||||
|
||||
export interface AdminDashboardMinutePoint {
|
||||
__typename?: 'AdminDashboardMinutePoint';
|
||||
activeUsers: Scalars['Int']['output'];
|
||||
minute: Scalars['DateTime']['output'];
|
||||
}
|
||||
|
||||
export interface AdminDashboardValueDayPoint {
|
||||
__typename?: 'AdminDashboardValueDayPoint';
|
||||
date: Scalars['DateTime']['output'];
|
||||
value: Scalars['SafeInt']['output'];
|
||||
}
|
||||
|
||||
export interface AdminSharedLinkTopItem {
|
||||
__typename?: 'AdminSharedLinkTopItem';
|
||||
docId: Scalars['String']['output'];
|
||||
guestViews: Scalars['SafeInt']['output'];
|
||||
lastAccessedAt: Maybe<Scalars['DateTime']['output']>;
|
||||
publishedAt: Maybe<Scalars['DateTime']['output']>;
|
||||
shareUrl: Scalars['String']['output'];
|
||||
title: Maybe<Scalars['String']['output']>;
|
||||
uniqueViews: Scalars['SafeInt']['output'];
|
||||
views: Scalars['SafeInt']['output'];
|
||||
workspaceId: Scalars['String']['output'];
|
||||
}
|
||||
|
||||
export enum AdminSharedLinksOrder {
|
||||
PublishedAtDesc = 'PublishedAtDesc',
|
||||
UpdatedAtDesc = 'UpdatedAtDesc',
|
||||
ViewsDesc = 'ViewsDesc',
|
||||
}
|
||||
|
||||
export interface AdminUpdateWorkspaceInput {
|
||||
avatarKey?: InputMaybe<Scalars['String']['input']>;
|
||||
enableAi?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
@@ -851,6 +936,19 @@ export interface DocHistoryType {
|
||||
workspaceId: Scalars['String']['output'];
|
||||
}
|
||||
|
||||
export interface DocMemberLastAccess {
|
||||
__typename?: 'DocMemberLastAccess';
|
||||
lastAccessedAt: Scalars['DateTime']['output'];
|
||||
lastDocId: Maybe<Scalars['String']['output']>;
|
||||
user: PublicUserType;
|
||||
}
|
||||
|
||||
export interface DocMemberLastAccessEdge {
|
||||
__typename?: 'DocMemberLastAccessEdge';
|
||||
cursor: Scalars['String']['output'];
|
||||
node: DocMemberLastAccess;
|
||||
}
|
||||
|
||||
/** Doc mode */
|
||||
export enum DocMode {
|
||||
edgeless = 'edgeless',
|
||||
@@ -863,6 +961,35 @@ export interface DocNotFoundDataType {
|
||||
spaceId: Scalars['String']['output'];
|
||||
}
|
||||
|
||||
export interface DocPageAnalytics {
|
||||
__typename?: 'DocPageAnalytics';
|
||||
generatedAt: Scalars['DateTime']['output'];
|
||||
series: Array<DocPageAnalyticsPoint>;
|
||||
summary: DocPageAnalyticsSummary;
|
||||
window: TimeWindow;
|
||||
}
|
||||
|
||||
export interface DocPageAnalyticsInput {
|
||||
timezone?: InputMaybe<Scalars['String']['input']>;
|
||||
windowDays?: InputMaybe<Scalars['Int']['input']>;
|
||||
}
|
||||
|
||||
export interface DocPageAnalyticsPoint {
|
||||
__typename?: 'DocPageAnalyticsPoint';
|
||||
date: Scalars['DateTime']['output'];
|
||||
guestViews: Scalars['SafeInt']['output'];
|
||||
totalViews: Scalars['SafeInt']['output'];
|
||||
uniqueViews: Scalars['SafeInt']['output'];
|
||||
}
|
||||
|
||||
export interface DocPageAnalyticsSummary {
|
||||
__typename?: 'DocPageAnalyticsSummary';
|
||||
guestViews: Scalars['SafeInt']['output'];
|
||||
lastAccessedAt: Maybe<Scalars['DateTime']['output']>;
|
||||
totalViews: Scalars['SafeInt']['output'];
|
||||
uniqueViews: Scalars['SafeInt']['output'];
|
||||
}
|
||||
|
||||
export interface DocPermissions {
|
||||
__typename?: 'DocPermissions';
|
||||
Doc_Comments_Create: Scalars['Boolean']['output'];
|
||||
@@ -897,6 +1024,8 @@ export enum DocRole {
|
||||
|
||||
export interface DocType {
|
||||
__typename?: 'DocType';
|
||||
/** Doc page analytics in a time window */
|
||||
analytics: DocPageAnalytics;
|
||||
createdAt: Maybe<Scalars['DateTime']['output']>;
|
||||
/** Doc create user */
|
||||
createdBy: Maybe<PublicUserType>;
|
||||
@@ -905,6 +1034,8 @@ export interface DocType {
|
||||
/** paginated doc granted users list */
|
||||
grantedUsersList: PaginatedGrantedDocUserType;
|
||||
id: Scalars['String']['output'];
|
||||
/** Paginated last accessed members of the current doc */
|
||||
lastAccessedMembers: PaginatedDocMemberLastAccess;
|
||||
/** Doc last updated user */
|
||||
lastUpdatedBy: Maybe<PublicUserType>;
|
||||
lastUpdaterId: Maybe<Scalars['String']['output']>;
|
||||
@@ -919,10 +1050,20 @@ export interface DocType {
|
||||
workspaceId: Scalars['String']['output'];
|
||||
}
|
||||
|
||||
export interface DocTypeAnalyticsArgs {
|
||||
input?: InputMaybe<DocPageAnalyticsInput>;
|
||||
}
|
||||
|
||||
export interface DocTypeGrantedUsersListArgs {
|
||||
pagination: PaginationInput;
|
||||
}
|
||||
|
||||
export interface DocTypeLastAccessedMembersArgs {
|
||||
includeTotal?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
pagination: PaginationInput;
|
||||
query?: InputMaybe<Scalars['String']['input']>;
|
||||
}
|
||||
|
||||
export interface DocTypeEdge {
|
||||
__typename?: 'DocTypeEdge';
|
||||
cursor: Scalars['String']['output'];
|
||||
@@ -2282,6 +2423,14 @@ export interface PageInfo {
|
||||
startCursor: Maybe<Scalars['String']['output']>;
|
||||
}
|
||||
|
||||
export interface PaginatedAdminAllSharedLink {
|
||||
__typename?: 'PaginatedAdminAllSharedLink';
|
||||
analyticsWindow: TimeWindow;
|
||||
edges: Array<AdminAllSharedLinkEdge>;
|
||||
pageInfo: PageInfo;
|
||||
totalCount: Maybe<Scalars['Int']['output']>;
|
||||
}
|
||||
|
||||
export interface PaginatedCommentChangeObjectType {
|
||||
__typename?: 'PaginatedCommentChangeObjectType';
|
||||
edges: Array<CommentChangeObjectTypeEdge>;
|
||||
@@ -2310,6 +2459,13 @@ export interface PaginatedCopilotWorkspaceFileType {
|
||||
totalCount: Scalars['Int']['output'];
|
||||
}
|
||||
|
||||
export interface PaginatedDocMemberLastAccess {
|
||||
__typename?: 'PaginatedDocMemberLastAccess';
|
||||
edges: Array<DocMemberLastAccessEdge>;
|
||||
pageInfo: PageInfo;
|
||||
totalCount: Maybe<Scalars['Int']['output']>;
|
||||
}
|
||||
|
||||
export interface PaginatedDocType {
|
||||
__typename?: 'PaginatedDocType';
|
||||
edges: Array<DocTypeEdge>;
|
||||
@@ -2376,6 +2532,10 @@ export interface PublicUserType {
|
||||
|
||||
export interface Query {
|
||||
__typename?: 'Query';
|
||||
/** List all shared links across workspaces for admin panel */
|
||||
adminAllSharedLinks: PaginatedAdminAllSharedLink;
|
||||
/** Get aggregated dashboard metrics for admin panel */
|
||||
adminDashboard: AdminDashboard;
|
||||
/** Get workspace detail for admin */
|
||||
adminWorkspace: Maybe<AdminWorkspace>;
|
||||
/** List workspaces for admin */
|
||||
@@ -2428,6 +2588,15 @@ export interface Query {
|
||||
workspaces: Array<WorkspaceType>;
|
||||
}
|
||||
|
||||
export interface QueryAdminAllSharedLinksArgs {
|
||||
filter?: InputMaybe<AdminAllSharedLinksFilterInput>;
|
||||
pagination: PaginationInput;
|
||||
}
|
||||
|
||||
export interface QueryAdminDashboardArgs {
|
||||
input?: InputMaybe<AdminDashboardInput>;
|
||||
}
|
||||
|
||||
export interface QueryAdminWorkspaceArgs {
|
||||
id: Scalars['String']['input'];
|
||||
}
|
||||
@@ -2871,6 +3040,21 @@ export enum SubscriptionVariant {
|
||||
Onetime = 'Onetime',
|
||||
}
|
||||
|
||||
export enum TimeBucket {
|
||||
Day = 'Day',
|
||||
Minute = 'Minute',
|
||||
}
|
||||
|
||||
export interface TimeWindow {
|
||||
__typename?: 'TimeWindow';
|
||||
bucket: TimeBucket;
|
||||
effectiveSize: Scalars['Int']['output'];
|
||||
from: Scalars['DateTime']['output'];
|
||||
requestedSize: Scalars['Int']['output'];
|
||||
timezone: Scalars['String']['output'];
|
||||
to: Scalars['DateTime']['output'];
|
||||
}
|
||||
|
||||
export interface TranscriptionItemType {
|
||||
__typename?: 'TranscriptionItemType';
|
||||
end: Scalars['String']['output'];
|
||||
@@ -3409,6 +3593,124 @@ export type RevokeUserAccessTokenMutation = {
|
||||
revokeUserAccessToken: boolean;
|
||||
};
|
||||
|
||||
export type AdminAllSharedLinksQueryVariables = Exact<{
|
||||
pagination: PaginationInput;
|
||||
filter?: InputMaybe<AdminAllSharedLinksFilterInput>;
|
||||
}>;
|
||||
|
||||
export type AdminAllSharedLinksQuery = {
|
||||
__typename?: 'Query';
|
||||
adminAllSharedLinks: {
|
||||
__typename?: 'PaginatedAdminAllSharedLink';
|
||||
totalCount: number | null;
|
||||
analyticsWindow: {
|
||||
__typename?: 'TimeWindow';
|
||||
from: string;
|
||||
to: string;
|
||||
timezone: string;
|
||||
bucket: TimeBucket;
|
||||
requestedSize: number;
|
||||
effectiveSize: number;
|
||||
};
|
||||
pageInfo: {
|
||||
__typename?: 'PageInfo';
|
||||
hasNextPage: boolean;
|
||||
hasPreviousPage: boolean;
|
||||
startCursor: string | null;
|
||||
endCursor: string | null;
|
||||
};
|
||||
edges: Array<{
|
||||
__typename?: 'AdminAllSharedLinkEdge';
|
||||
cursor: string;
|
||||
node: {
|
||||
__typename?: 'AdminAllSharedLink';
|
||||
workspaceId: string;
|
||||
docId: string;
|
||||
title: string | null;
|
||||
publishedAt: string | null;
|
||||
docUpdatedAt: string | null;
|
||||
workspaceOwnerId: string | null;
|
||||
lastUpdaterId: string | null;
|
||||
shareUrl: string;
|
||||
views: number | null;
|
||||
uniqueViews: number | null;
|
||||
guestViews: number | null;
|
||||
lastAccessedAt: string | null;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
export type AdminDashboardQueryVariables = Exact<{
|
||||
input?: InputMaybe<AdminDashboardInput>;
|
||||
}>;
|
||||
|
||||
export type AdminDashboardQuery = {
|
||||
__typename?: 'Query';
|
||||
adminDashboard: {
|
||||
__typename?: 'AdminDashboard';
|
||||
syncActiveUsers: number;
|
||||
copilotConversations: number;
|
||||
workspaceStorageBytes: number;
|
||||
blobStorageBytes: number;
|
||||
generatedAt: string;
|
||||
syncActiveUsersTimeline: Array<{
|
||||
__typename?: 'AdminDashboardMinutePoint';
|
||||
minute: string;
|
||||
activeUsers: number;
|
||||
}>;
|
||||
syncWindow: {
|
||||
__typename?: 'TimeWindow';
|
||||
from: string;
|
||||
to: string;
|
||||
timezone: string;
|
||||
bucket: TimeBucket;
|
||||
requestedSize: number;
|
||||
effectiveSize: number;
|
||||
};
|
||||
workspaceStorageHistory: Array<{
|
||||
__typename?: 'AdminDashboardValueDayPoint';
|
||||
date: string;
|
||||
value: number;
|
||||
}>;
|
||||
blobStorageHistory: Array<{
|
||||
__typename?: 'AdminDashboardValueDayPoint';
|
||||
date: string;
|
||||
value: number;
|
||||
}>;
|
||||
storageWindow: {
|
||||
__typename?: 'TimeWindow';
|
||||
from: string;
|
||||
to: string;
|
||||
timezone: string;
|
||||
bucket: TimeBucket;
|
||||
requestedSize: number;
|
||||
effectiveSize: number;
|
||||
};
|
||||
topSharedLinks: Array<{
|
||||
__typename?: 'AdminSharedLinkTopItem';
|
||||
workspaceId: string;
|
||||
docId: string;
|
||||
title: string | null;
|
||||
shareUrl: string;
|
||||
publishedAt: string | null;
|
||||
views: number;
|
||||
uniqueViews: number;
|
||||
guestViews: number;
|
||||
lastAccessedAt: string | null;
|
||||
}>;
|
||||
topSharedLinksWindow: {
|
||||
__typename?: 'TimeWindow';
|
||||
from: string;
|
||||
to: string;
|
||||
timezone: string;
|
||||
bucket: TimeBucket;
|
||||
requestedSize: number;
|
||||
effectiveSize: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export type AdminServerConfigQueryVariables = Exact<{ [key: string]: never }>;
|
||||
|
||||
export type AdminServerConfigQuery = {
|
||||
@@ -5916,6 +6218,93 @@ export type GetDocDefaultRoleQuery = {
|
||||
};
|
||||
};
|
||||
|
||||
export type GetDocLastAccessedMembersQueryVariables = Exact<{
|
||||
workspaceId: Scalars['String']['input'];
|
||||
docId: Scalars['String']['input'];
|
||||
pagination: PaginationInput;
|
||||
query?: InputMaybe<Scalars['String']['input']>;
|
||||
includeTotal?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
}>;
|
||||
|
||||
export type GetDocLastAccessedMembersQuery = {
|
||||
__typename?: 'Query';
|
||||
workspace: {
|
||||
__typename?: 'WorkspaceType';
|
||||
doc: {
|
||||
__typename?: 'DocType';
|
||||
lastAccessedMembers: {
|
||||
__typename?: 'PaginatedDocMemberLastAccess';
|
||||
totalCount: number | null;
|
||||
pageInfo: {
|
||||
__typename?: 'PageInfo';
|
||||
hasNextPage: boolean;
|
||||
hasPreviousPage: boolean;
|
||||
startCursor: string | null;
|
||||
endCursor: string | null;
|
||||
};
|
||||
edges: Array<{
|
||||
__typename?: 'DocMemberLastAccessEdge';
|
||||
cursor: string;
|
||||
node: {
|
||||
__typename?: 'DocMemberLastAccess';
|
||||
lastAccessedAt: string;
|
||||
lastDocId: string | null;
|
||||
user: {
|
||||
__typename?: 'PublicUserType';
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl: string | null;
|
||||
};
|
||||
};
|
||||
}>;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export type GetDocPageAnalyticsQueryVariables = Exact<{
|
||||
workspaceId: Scalars['String']['input'];
|
||||
docId: Scalars['String']['input'];
|
||||
input?: InputMaybe<DocPageAnalyticsInput>;
|
||||
}>;
|
||||
|
||||
export type GetDocPageAnalyticsQuery = {
|
||||
__typename?: 'Query';
|
||||
workspace: {
|
||||
__typename?: 'WorkspaceType';
|
||||
doc: {
|
||||
__typename?: 'DocType';
|
||||
analytics: {
|
||||
__typename?: 'DocPageAnalytics';
|
||||
generatedAt: string;
|
||||
window: {
|
||||
__typename?: 'TimeWindow';
|
||||
from: string;
|
||||
to: string;
|
||||
timezone: string;
|
||||
bucket: TimeBucket;
|
||||
requestedSize: number;
|
||||
effectiveSize: number;
|
||||
};
|
||||
series: Array<{
|
||||
__typename?: 'DocPageAnalyticsPoint';
|
||||
date: string;
|
||||
totalViews: number;
|
||||
uniqueViews: number;
|
||||
guestViews: number;
|
||||
}>;
|
||||
summary: {
|
||||
__typename?: 'DocPageAnalyticsSummary';
|
||||
totalViews: number;
|
||||
uniqueViews: number;
|
||||
guestViews: number;
|
||||
lastAccessedAt: string | null;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export type GetDocSummaryQueryVariables = Exact<{
|
||||
workspaceId: Scalars['String']['input'];
|
||||
docId: Scalars['String']['input'];
|
||||
@@ -7199,6 +7588,16 @@ export type Queries =
|
||||
variables: ListUserAccessTokensQueryVariables;
|
||||
response: ListUserAccessTokensQuery;
|
||||
}
|
||||
| {
|
||||
name: 'adminAllSharedLinksQuery';
|
||||
variables: AdminAllSharedLinksQueryVariables;
|
||||
response: AdminAllSharedLinksQuery;
|
||||
}
|
||||
| {
|
||||
name: 'adminDashboardQuery';
|
||||
variables: AdminDashboardQueryVariables;
|
||||
response: AdminDashboardQuery;
|
||||
}
|
||||
| {
|
||||
name: 'adminServerConfigQuery';
|
||||
variables: AdminServerConfigQueryVariables;
|
||||
@@ -7419,6 +7818,16 @@ export type Queries =
|
||||
variables: GetDocDefaultRoleQueryVariables;
|
||||
response: GetDocDefaultRoleQuery;
|
||||
}
|
||||
| {
|
||||
name: 'getDocLastAccessedMembersQuery';
|
||||
variables: GetDocLastAccessedMembersQueryVariables;
|
||||
response: GetDocLastAccessedMembersQuery;
|
||||
}
|
||||
| {
|
||||
name: 'getDocPageAnalyticsQuery';
|
||||
variables: GetDocPageAnalyticsQueryVariables;
|
||||
response: GetDocPageAnalyticsQuery;
|
||||
}
|
||||
| {
|
||||
name: 'getDocSummaryQuery';
|
||||
variables: GetDocSummaryQueryVariables;
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
"react-hook-form": "^7.54.1",
|
||||
"react-resizable-panels": "^3.0.6",
|
||||
"react-router-dom": "^7.12.0",
|
||||
"recharts": "^2.15.4",
|
||||
"sonner": "^2.0.7",
|
||||
"swr": "^2.3.7",
|
||||
"vaul": "^1.1.2",
|
||||
|
||||
@@ -23,6 +23,9 @@ export const Setup = lazy(
|
||||
export const Accounts = lazy(
|
||||
() => import(/* webpackChunkName: "accounts" */ './modules/accounts')
|
||||
);
|
||||
export const Dashboard = lazy(
|
||||
() => import(/* webpackChunkName: "dashboard" */ './modules/dashboard')
|
||||
);
|
||||
export const Workspaces = lazy(
|
||||
() => import(/* webpackChunkName: "workspaces" */ './modules/workspaces')
|
||||
);
|
||||
@@ -75,7 +78,15 @@ function RootRoutes() {
|
||||
}
|
||||
|
||||
if (/^\/admin\/?$/.test(location.pathname)) {
|
||||
return <Navigate to="/admin/accounts" />;
|
||||
return (
|
||||
<Navigate
|
||||
to={
|
||||
environment.isSelfHosted
|
||||
? ROUTES.admin.accounts
|
||||
: ROUTES.admin.dashboard
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
@@ -96,6 +107,16 @@ export const App = () => {
|
||||
<Route path={ROUTES.admin.auth} element={<Auth />} />
|
||||
<Route path={ROUTES.admin.setup} element={<Setup />} />
|
||||
<Route element={<AuthenticatedRoutes />}>
|
||||
<Route
|
||||
path={ROUTES.admin.dashboard}
|
||||
element={
|
||||
environment.isSelfHosted ? (
|
||||
<Navigate to={ROUTES.admin.accounts} replace />
|
||||
) : (
|
||||
<Dashboard />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route path={ROUTES.admin.accounts} element={<Accounts />} />
|
||||
<Route
|
||||
path={ROUTES.admin.workspaces}
|
||||
|
||||
173
packages/frontend/admin/src/components/ui/chart.tsx
Normal file
173
packages/frontend/admin/src/components/ui/chart.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import { cn } from '@affine/admin/utils';
|
||||
import * as React from 'react';
|
||||
import type { TooltipProps } from 'recharts';
|
||||
import { ResponsiveContainer, Tooltip as RechartsTooltip } from 'recharts';
|
||||
|
||||
const THEMES = { light: '', dark: '.dark' } as const;
|
||||
|
||||
export type ChartConfig = Record<
|
||||
string,
|
||||
{
|
||||
label?: React.ReactNode;
|
||||
color?: string;
|
||||
theme?: Partial<Record<keyof typeof THEMES, string>>;
|
||||
}
|
||||
>;
|
||||
|
||||
type ChartContextValue = {
|
||||
config: ChartConfig;
|
||||
};
|
||||
|
||||
const ChartContext = React.createContext<ChartContextValue | null>(null);
|
||||
|
||||
function useChart() {
|
||||
const value = React.useContext(ChartContext);
|
||||
if (!value) {
|
||||
throw new Error('useChart must be used within <ChartContainer />');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function ChartStyle({
|
||||
chartId,
|
||||
config,
|
||||
}: {
|
||||
chartId: string;
|
||||
config: ChartConfig;
|
||||
}) {
|
||||
const colorEntries = Object.entries(config).filter(
|
||||
([, item]) => item.color || item.theme
|
||||
);
|
||||
|
||||
if (!colorEntries.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const css = Object.entries(THEMES)
|
||||
.map(([themeKey, prefix]) => {
|
||||
const declarations = colorEntries
|
||||
.map(([key, item]) => {
|
||||
const color =
|
||||
item.theme?.[themeKey as keyof typeof THEMES] ?? item.color;
|
||||
return color ? ` --color-${key}: ${color};` : '';
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
|
||||
if (!declarations) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return `${prefix} [data-chart="${chartId}"] {\n${declarations}\n}`;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
|
||||
if (!css) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <style dangerouslySetInnerHTML={{ __html: css }} />;
|
||||
}
|
||||
|
||||
type ChartContainerProps = React.ComponentProps<'div'> & {
|
||||
config: ChartConfig;
|
||||
children: React.ComponentProps<typeof ResponsiveContainer>['children'];
|
||||
};
|
||||
|
||||
const ChartContainer = React.forwardRef<HTMLDivElement, ChartContainerProps>(
|
||||
({ id, className, children, config, ...props }, ref) => {
|
||||
const uniqueId = React.useId();
|
||||
const chartId = `chart-${id ?? uniqueId.replace(/:/g, '')}`;
|
||||
|
||||
return (
|
||||
<ChartContext.Provider value={{ config }}>
|
||||
<div
|
||||
ref={ref}
|
||||
data-chart={chartId}
|
||||
className={cn(
|
||||
'flex min-h-0 w-full items-center justify-center text-xs',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChartStyle chartId={chartId} config={config} />
|
||||
<ResponsiveContainer>{children}</ResponsiveContainer>
|
||||
</div>
|
||||
</ChartContext.Provider>
|
||||
);
|
||||
}
|
||||
);
|
||||
ChartContainer.displayName = 'ChartContainer';
|
||||
|
||||
const ChartTooltip = RechartsTooltip;
|
||||
|
||||
type TooltipContentProps = {
|
||||
active?: boolean;
|
||||
payload?: TooltipProps<number, string>['payload'];
|
||||
label?: string | number;
|
||||
labelFormatter?: (
|
||||
label: string | number,
|
||||
payload: TooltipProps<number, string>['payload']
|
||||
) => React.ReactNode;
|
||||
valueFormatter?: (value: number, key: string) => React.ReactNode;
|
||||
};
|
||||
|
||||
const ChartTooltipContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
TooltipContentProps
|
||||
>(({ active, payload, label, labelFormatter, valueFormatter }, ref) => {
|
||||
const { config } = useChart();
|
||||
|
||||
if (!active || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const title = labelFormatter ? labelFormatter(label ?? '', payload) : label;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="min-w-44 rounded-md border bg-popover px-3 py-2 text-xs text-popover-foreground shadow-md"
|
||||
>
|
||||
{title ? (
|
||||
<div className="mb-2 font-medium text-foreground/90">{title}</div>
|
||||
) : null}
|
||||
<div className="space-y-1">
|
||||
{payload.map((item, index) => {
|
||||
const dataKey = String(item.dataKey ?? item.name ?? index);
|
||||
const itemConfig = config[dataKey];
|
||||
const labelText = itemConfig?.label ?? item.name ?? dataKey;
|
||||
const numericValue =
|
||||
typeof item.value === 'number'
|
||||
? item.value
|
||||
: Number(item.value ?? 0);
|
||||
const valueText = valueFormatter
|
||||
? valueFormatter(numericValue, dataKey)
|
||||
: numericValue;
|
||||
const color = item.color ?? `var(--color-${dataKey})`;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${dataKey}-${index}`}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<span
|
||||
className="h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: color }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="text-muted-foreground">{labelText}</span>
|
||||
<span className="ml-auto font-medium tabular-nums">
|
||||
{valueText}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
ChartTooltipContent.displayName = 'ChartTooltipContent';
|
||||
|
||||
export { ChartContainer, ChartTooltip, ChartTooltipContent };
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user