mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Compare commits
108 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bd0111785a | |||
| 5c46bd13ed | |||
| e51f93f9a0 | |||
| d4fe240808 | |||
| 61c76e62ef | |||
| 499392c114 | |||
| af0651f243 | |||
| 7a54d5bb84 | |||
| cb4a978a87 | |||
| 66a5055e73 | |||
| a9ddbde02c | |||
| 6b25000adb | |||
| 0fb8971628 | |||
| 9f277a8f7b | |||
| 97e91eb06b | |||
| a87bda82b1 | |||
| 36a92d5393 | |||
| 50f9f414bf | |||
| 0d6026c21f | |||
| eea0e28630 | |||
| 4ad58b4ccd | |||
| 17f4dc58f1 | |||
| fe839acaeb | |||
| 210aefaf8f | |||
| 90f7a4272e | |||
| 978773ee27 | |||
| 59abc3355c | |||
| 72aa4cde3f | |||
| 42dfe7027f | |||
| 3d4299bc60 | |||
| 6a1f2399db | |||
| ae2fafac0e | |||
| 0efe6393a3 | |||
| 2e3c19ff88 | |||
| 87fcf35956 | |||
| 540683d896 | |||
| a8cbdf061d | |||
| 171433e984 | |||
| f61046ec22 | |||
| 78ff3af801 | |||
| 83da38afd5 | |||
| b2da166dd6 | |||
| 33c7560b3d | |||
| db78fb7111 | |||
| cbca7f60fe | |||
| c89589e86c | |||
| 878a27b7c6 | |||
| a05c965be2 | |||
| 2116041cd5 | |||
| 7144536eb3 | |||
| 4cd2ee6291 | |||
| 1749ffe20d | |||
| b9c6f9c9e6 | |||
| 14777145e9 | |||
| c6ae6e0c36 | |||
| 84542874c4 | |||
| e90a86737f | |||
| 4373dad309 | |||
| 839ce889ad | |||
| 27322d62f8 | |||
| a9b41b3f17 | |||
| f46921275d | |||
| 433c3b299d | |||
| c44872b4cc | |||
| 5132c5814b | |||
| acc825b554 | |||
| bef4292146 | |||
| 0b13698998 | |||
| ac45e3c0db | |||
| 6a633f5a4c | |||
| 67c114e6ed | |||
| 483fe95856 | |||
| 06f1f0431f | |||
| ca38523d9b | |||
| c725302701 | |||
| 7bc687b6bf | |||
| bb397b8625 | |||
| edd413fba3 | |||
| 3f8fb66be1 | |||
| 4b379a4dc4 | |||
| 0bcff545e7 | |||
| 93e8cbb541 | |||
| 9e8b4a3269 | |||
| d48386797e | |||
| 898e11b424 | |||
| ac48767132 | |||
| 854fbca420 | |||
| 82539cc348 | |||
| 027522350f | |||
| eb92a206fb | |||
| dfc3c05c40 | |||
| 59fa91413d | |||
| 205ca03ced | |||
| 0432144d1e | |||
| c81802b3bb | |||
| 6ecf9ca9c3 | |||
| b788b95880 | |||
| ff0bebaf63 | |||
| f53c2828ef | |||
| 926a4e2224 | |||
| 12efdf4e50 | |||
| 49b2fad6ce | |||
| 2edd48ab84 | |||
| 146cf56bce | |||
| d37e21645c | |||
| fe2c9b5817 | |||
| ec86e80edb | |||
| fa19b278a4 |
@@ -1,183 +0,0 @@
|
||||
version: 2.1
|
||||
|
||||
defaults: &defaults
|
||||
working_directory: ~/outline
|
||||
docker:
|
||||
- image: cimg/node:20.10
|
||||
resource_class: large
|
||||
environment:
|
||||
NODE_ENV: test
|
||||
DATABASE_URL: postgres://postgres:password@localhost:5432/circle_test
|
||||
URL: http://localhost:3000
|
||||
NODE_OPTIONS: --max-old-space-size=8000
|
||||
|
||||
executors:
|
||||
docker-publisher:
|
||||
environment:
|
||||
IMAGE_NAME: outlinewiki/outline
|
||||
BASE_IMAGE_NAME: outlinewiki/outline-base
|
||||
docker:
|
||||
- image: circleci/buildpack-deps:stretch
|
||||
|
||||
jobs:
|
||||
build:
|
||||
<<: *defaults
|
||||
steps:
|
||||
- checkout
|
||||
- restore_cache:
|
||||
key: dependency-cache-v1-{{ checksum "package.json" }}
|
||||
- run:
|
||||
name: install-deps
|
||||
command: yarn install --frozen-lockfile
|
||||
- save_cache:
|
||||
key: dependency-cache-v1-{{ checksum "package.json" }}
|
||||
paths:
|
||||
- ./node_modules
|
||||
lint:
|
||||
<<: *defaults
|
||||
steps:
|
||||
- checkout
|
||||
- restore_cache:
|
||||
key: dependency-cache-v1-{{ checksum "package.json" }}
|
||||
- run:
|
||||
name: lint
|
||||
command: yarn lint
|
||||
types:
|
||||
<<: *defaults
|
||||
steps:
|
||||
- checkout
|
||||
- restore_cache:
|
||||
key: dependency-cache-v1-{{ checksum "package.json" }}
|
||||
- run:
|
||||
name: typescript
|
||||
command: yarn tsc
|
||||
test-app:
|
||||
<<: *defaults
|
||||
steps:
|
||||
- checkout
|
||||
- restore_cache:
|
||||
key: dependency-cache-v1-{{ checksum "package.json" }}
|
||||
- run:
|
||||
name: test
|
||||
command: yarn test:app
|
||||
test-shared:
|
||||
<<: *defaults
|
||||
steps:
|
||||
- checkout
|
||||
- restore_cache:
|
||||
key: dependency-cache-v1-{{ checksum "package.json" }}
|
||||
- run:
|
||||
name: test
|
||||
command: yarn test:shared
|
||||
test-server:
|
||||
<<: *defaults
|
||||
parallelism: 3
|
||||
docker:
|
||||
- image: cimg/node:20.10
|
||||
- image: cimg/redis:5.0
|
||||
- image: cimg/postgres:14.2
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: password
|
||||
POSTGRES_DB: circle_test
|
||||
steps:
|
||||
- checkout
|
||||
- restore_cache:
|
||||
key: dependency-cache-v1-{{ checksum "package.json" }}
|
||||
- run:
|
||||
name: migrate
|
||||
command: ./node_modules/.bin/sequelize db:migrate
|
||||
- run:
|
||||
name: test
|
||||
command: |
|
||||
TESTFILES=$(circleci tests glob "**/server/**/*.test.ts" | circleci tests split)
|
||||
yarn test --maxWorkers=2 $TESTFILES
|
||||
bundle-size:
|
||||
<<: *defaults
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
steps:
|
||||
- checkout
|
||||
- restore_cache:
|
||||
key: dependency-cache-v1-{{ checksum "package.json" }}
|
||||
- run:
|
||||
name: build-vite
|
||||
command: yarn vite:build
|
||||
- run:
|
||||
name: Send bundle stats to RelativeCI
|
||||
command: npx relative-ci-agent
|
||||
build-image:
|
||||
executor: docker-publisher
|
||||
steps:
|
||||
- checkout
|
||||
- setup_remote_docker
|
||||
- run:
|
||||
name: Install Docker buildx
|
||||
command: |
|
||||
mkdir -p ~/.docker/cli-plugins
|
||||
url="https://github.com/docker/buildx/releases/download/v0.8.0/buildx-v0.8.0.linux-amd64"
|
||||
curl -sSL -o ~/.docker/cli-plugins/docker-buildx $url
|
||||
chmod a+x ~/.docker/cli-plugins/docker-buildx
|
||||
- run:
|
||||
name: Enable Docker buildx
|
||||
command: export DOCKER_CLI_EXPERIMENTAL=enabled
|
||||
- run:
|
||||
name: Initialize Docker buildx
|
||||
command: |
|
||||
docker buildx install
|
||||
docker context create docker-multiarch
|
||||
docker run --rm --privileged multiarch/qemu-user-static --reset -p yes
|
||||
docker buildx create --name docker-multiarch --platform linux/amd64,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x docker-multiarch
|
||||
docker buildx inspect --builder docker-multiarch --bootstrap
|
||||
docker buildx use docker-multiarch
|
||||
- run:
|
||||
name: Build base image
|
||||
command: docker build -f Dockerfile.base -t $BASE_IMAGE_NAME:latest --load .
|
||||
- run:
|
||||
name: Login to Docker Hub
|
||||
command: echo "$DOCKERHUB_PASS" | docker login -u "$DOCKERHUB_USERNAME" --password-stdin
|
||||
- run:
|
||||
name: Publish base Docker Image to Docker Hub
|
||||
command: docker push $BASE_IMAGE_NAME:latest
|
||||
- run:
|
||||
name: Build and push Docker image
|
||||
command: |
|
||||
if [[ "$CIRCLE_TAG" == *"-"* ]]; then
|
||||
docker buildx build -t $IMAGE_NAME:${CIRCLE_TAG/v/''} --platform linux/amd64,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x --push .
|
||||
else
|
||||
docker buildx build -t $IMAGE_NAME:latest -t $IMAGE_NAME:${CIRCLE_TAG/v/''} --platform linux/amd64,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x --push .
|
||||
fi
|
||||
|
||||
workflows:
|
||||
version: 2
|
||||
all:
|
||||
jobs:
|
||||
- build
|
||||
- lint:
|
||||
requires:
|
||||
- build
|
||||
- test-server:
|
||||
requires:
|
||||
- build
|
||||
- test-shared:
|
||||
requires:
|
||||
- build
|
||||
- test-app:
|
||||
requires:
|
||||
- build
|
||||
- types:
|
||||
requires:
|
||||
- build
|
||||
- bundle-size:
|
||||
requires:
|
||||
- build
|
||||
- types
|
||||
|
||||
build-docker:
|
||||
jobs:
|
||||
- build-image:
|
||||
filters:
|
||||
tags:
|
||||
only: /^v.*/
|
||||
branches:
|
||||
ignore: /.*/
|
||||
@@ -1,7 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
curl --user ${CIRCLE_TOKEN}: \
|
||||
--request POST \
|
||||
--form revision=<ENTER COMMIT SHA HERE>\
|
||||
--form config=@config.yml \
|
||||
--form notify=false \
|
||||
https://circleci.com/api/v1.1/project/github/outline/outline/tree/master
|
||||
@@ -0,0 +1,163 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
env:
|
||||
NODE_ENV: test
|
||||
DATABASE_URL: postgres://postgres:password@localhost:5432/outline_test
|
||||
REDIS_URL: redis://127.0.0.1:6379
|
||||
URL: http://localhost:3000
|
||||
NODE_OPTIONS: --max-old-space-size=8000
|
||||
SECRET_KEY: F0E5AD933D7F6FD8F4DBB3E038C501C052DC0593C686D21ACB30AE205D2F634B
|
||||
UTILS_SECRET: 123456
|
||||
SLACK_VERIFICATION_TOKEN: 123456
|
||||
SMTP_USERNAME: localhost
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [20.x]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: 'yarn'
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --frozen-lockfile
|
||||
|
||||
lint:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
cache: 'yarn'
|
||||
- run: yarn install --frozen-lockfile
|
||||
- run: yarn lint
|
||||
|
||||
types:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
cache: 'yarn'
|
||||
- run: yarn install --frozen-lockfile
|
||||
- run: yarn tsc
|
||||
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
server: ${{ steps.filter.outputs.server }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dorny/paths-filter@v2
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
server:
|
||||
- 'server/**'
|
||||
- 'shared/**'
|
||||
- 'package.json'
|
||||
- 'yarn.lock'
|
||||
app:
|
||||
- 'app/**'
|
||||
- 'shared/**'
|
||||
- 'package.json'
|
||||
- 'yarn.lock'
|
||||
|
||||
test:
|
||||
needs: build
|
||||
if: ${{ needs.changes.outputs.app == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
test-group: [app, shared]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
cache: 'yarn'
|
||||
- run: yarn install --frozen-lockfile
|
||||
- run: yarn test:${{ matrix.test-group }}
|
||||
|
||||
test-server:
|
||||
needs: [build, changes]
|
||||
if: ${{ needs.changes.outputs.server == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:14.2
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: password
|
||||
POSTGRES_DB: outline_test
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
redis:
|
||||
image: redis:5.0
|
||||
ports:
|
||||
- 6379:6379
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
shard: [1, 2, 3]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
cache: 'yarn'
|
||||
- run: yarn install --frozen-lockfile
|
||||
- run: yarn sequelize db:migrate
|
||||
- name: Run server tests
|
||||
run: |
|
||||
TESTFILES=$(find . -name "*.test.ts" -path "*/server/*" | sort | split -n -d -l $(($(find . -name "*.test.ts" -path "*/server/*" | wc -l)/${{ matrix.shard }})) - | sed -n "${{ matrix.shard }}p")
|
||||
yarn test --maxWorkers=2 $TESTFILES
|
||||
|
||||
bundle-size:
|
||||
needs: [build, types]
|
||||
if: ${{ needs.changes.outputs.app == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
cache: 'yarn'
|
||||
- run: yarn install --frozen-lockfile
|
||||
- name: Set environment to production
|
||||
run: echo "NODE_ENV=production" >> $GITHUB_ENV
|
||||
- run: yarn vite:build
|
||||
- name: Send bundle stats to RelativeCI
|
||||
uses: relative-ci/agent-action@v2
|
||||
with:
|
||||
key: ${{ secrets.RELATIVE_CI_KEY }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -0,0 +1,52 @@
|
||||
name: Docker
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
env:
|
||||
IMAGE_NAME: outlinewiki/outline
|
||||
BASE_IMAGE_NAME: outlinewiki/outline-base
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push base image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.base
|
||||
push: true
|
||||
tags: ${{ env.BASE_IMAGE_NAME }}:latest
|
||||
platforms: linux/amd64,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x
|
||||
|
||||
- name: Extract version
|
||||
id: version
|
||||
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and push main image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x
|
||||
tags: |
|
||||
${{ env.IMAGE_NAME }}:latest
|
||||
${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}
|
||||
@@ -24,6 +24,6 @@ jobs:
|
||||
operations-per-run: 60
|
||||
stale-issue-label: stale
|
||||
stale-pr-label: stale
|
||||
exempt-issue-labels: "security,pinned"
|
||||
exempt-issue-labels: "security,pinned,A1"
|
||||
- name: Print outputs
|
||||
run: echo ${{ join(steps.stale.outputs.*, ',') }}
|
||||
|
||||
@@ -3,8 +3,8 @@ Business Source License 1.1
|
||||
Parameters
|
||||
|
||||
Licensor: General Outline, Inc.
|
||||
Licensed Work: Outline 0.81.0
|
||||
The Licensed Work is (c) 2024 General Outline, Inc.
|
||||
Licensed Work: Outline 0.82.0
|
||||
The Licensed Work is (c) 2025 General Outline, Inc.
|
||||
Additional Use Grant: You may make use of the Licensed Work, provided that
|
||||
you may not use the Licensed Work for a Document
|
||||
Service.
|
||||
@@ -15,7 +15,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that
|
||||
Licensed Work by creating teams and documents
|
||||
controlled by such third parties.
|
||||
|
||||
Change Date: 2028-11-11
|
||||
Change Date: 2029-02-15
|
||||
|
||||
Change License: Apache License, Version 2.0
|
||||
|
||||
|
||||
@@ -8,12 +8,13 @@ import {
|
||||
SearchIcon,
|
||||
ShapesIcon,
|
||||
StarredIcon,
|
||||
SubscribeIcon,
|
||||
TrashIcon,
|
||||
UnstarredIcon,
|
||||
UnsubscribeIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
import stores from "~/stores";
|
||||
import Collection from "~/models/Collection";
|
||||
import { CollectionEdit } from "~/components/Collection/CollectionEdit";
|
||||
import { CollectionNew } from "~/components/Collection/CollectionNew";
|
||||
@@ -60,7 +61,7 @@ export const createCollection = createAction({
|
||||
keywords: "create",
|
||||
visible: ({ stores }) =>
|
||||
stores.policies.abilities(stores.auth.team?.id || "").createCollection,
|
||||
perform: ({ t, event }) => {
|
||||
perform: ({ t, event, stores }) => {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
stores.dialogs.openModal({
|
||||
@@ -76,10 +77,10 @@ export const editCollection = createAction({
|
||||
analyticsName: "Edit collection",
|
||||
section: ActiveCollectionSection,
|
||||
icon: <EditIcon />,
|
||||
visible: ({ activeCollectionId }) =>
|
||||
visible: ({ activeCollectionId, stores }) =>
|
||||
!!activeCollectionId &&
|
||||
stores.policies.abilities(activeCollectionId).update,
|
||||
perform: ({ t, activeCollectionId }) => {
|
||||
perform: ({ t, activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
@@ -102,10 +103,10 @@ export const editCollectionPermissions = createAction({
|
||||
analyticsName: "Collection permissions",
|
||||
section: ActiveCollectionSection,
|
||||
icon: <PadlockIcon />,
|
||||
visible: ({ activeCollectionId }) =>
|
||||
visible: ({ activeCollectionId, stores }) =>
|
||||
!!activeCollectionId &&
|
||||
stores.policies.abilities(activeCollectionId).update,
|
||||
perform: ({ t, activeCollectionId }) => {
|
||||
perform: ({ t, activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
@@ -133,7 +134,7 @@ export const searchInCollection = createAction({
|
||||
analyticsName: "Search collection",
|
||||
section: ActiveCollectionSection,
|
||||
icon: <SearchIcon />,
|
||||
visible: ({ activeCollectionId }) => {
|
||||
visible: ({ activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return false;
|
||||
}
|
||||
@@ -158,7 +159,7 @@ export const starCollection = createAction({
|
||||
section: ActiveCollectionSection,
|
||||
icon: <StarredIcon />,
|
||||
keywords: "favorite bookmark",
|
||||
visible: ({ activeCollectionId }) => {
|
||||
visible: ({ activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return false;
|
||||
}
|
||||
@@ -168,7 +169,7 @@ export const starCollection = createAction({
|
||||
stores.policies.abilities(activeCollectionId).star
|
||||
);
|
||||
},
|
||||
perform: async ({ activeCollectionId }) => {
|
||||
perform: async ({ activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
@@ -185,7 +186,7 @@ export const unstarCollection = createAction({
|
||||
section: ActiveCollectionSection,
|
||||
icon: <UnstarredIcon />,
|
||||
keywords: "unfavorite unbookmark",
|
||||
visible: ({ activeCollectionId }) => {
|
||||
visible: ({ activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return false;
|
||||
}
|
||||
@@ -195,7 +196,7 @@ export const unstarCollection = createAction({
|
||||
stores.policies.abilities(activeCollectionId).unstar
|
||||
);
|
||||
},
|
||||
perform: async ({ activeCollectionId }) => {
|
||||
perform: async ({ activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
@@ -205,6 +206,66 @@ export const unstarCollection = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const subscribeCollection = createAction({
|
||||
name: ({ t }) => t("Subscribe"),
|
||||
analyticsName: "Subscribe to collection",
|
||||
section: ActiveCollectionSection,
|
||||
icon: <SubscribeIcon />,
|
||||
visible: ({ activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
|
||||
return (
|
||||
!collection?.isSubscribed &&
|
||||
stores.policies.abilities(activeCollectionId).subscribe
|
||||
);
|
||||
},
|
||||
perform: async ({ activeCollectionId, stores, t }) => {
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
|
||||
await collection?.subscribe();
|
||||
|
||||
toast.success(t("Subscribed to document notifications"));
|
||||
},
|
||||
});
|
||||
|
||||
export const unsubscribeCollection = createAction({
|
||||
name: ({ t }) => t("Unsubscribe"),
|
||||
analyticsName: "Unsubscribe from collection",
|
||||
section: ActiveCollectionSection,
|
||||
icon: <UnsubscribeIcon />,
|
||||
visible: ({ activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
|
||||
return (
|
||||
!!collection?.isSubscribed &&
|
||||
stores.policies.abilities(activeCollectionId).unsubscribe
|
||||
);
|
||||
},
|
||||
perform: async ({ activeCollectionId, currentUserId, stores, t }) => {
|
||||
if (!activeCollectionId || !currentUserId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
|
||||
await collection?.unsubscribe();
|
||||
|
||||
toast.success(t("Unsubscribed from document notifications"));
|
||||
},
|
||||
});
|
||||
|
||||
export const archiveCollection = createAction({
|
||||
name: ({ t }) => `${t("Archive")}…`,
|
||||
analyticsName: "Archive collection",
|
||||
@@ -277,13 +338,13 @@ export const deleteCollection = createAction({
|
||||
section: ActiveCollectionSection,
|
||||
dangerous: true,
|
||||
icon: <TrashIcon />,
|
||||
visible: ({ activeCollectionId }) => {
|
||||
visible: ({ activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return false;
|
||||
}
|
||||
return stores.policies.abilities(activeCollectionId).delete;
|
||||
},
|
||||
perform: ({ activeCollectionId, t }) => {
|
||||
perform: ({ activeCollectionId, t, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
@@ -311,7 +372,7 @@ export const createTemplate = createAction({
|
||||
section: ActiveCollectionSection,
|
||||
icon: <ShapesIcon />,
|
||||
keywords: "new create template",
|
||||
visible: ({ activeCollectionId }) =>
|
||||
visible: ({ activeCollectionId, stores }) =>
|
||||
!!(
|
||||
!!activeCollectionId &&
|
||||
stores.policies.abilities(activeCollectionId).createDocument
|
||||
@@ -331,5 +392,7 @@ export const rootCollectionActions = [
|
||||
createCollection,
|
||||
starCollection,
|
||||
unstarCollection,
|
||||
subscribeCollection,
|
||||
unsubscribeCollection,
|
||||
deleteCollection,
|
||||
];
|
||||
|
||||
@@ -2,6 +2,7 @@ import copy from "copy-to-clipboard";
|
||||
import {
|
||||
BeakerIcon,
|
||||
CopyIcon,
|
||||
EditIcon,
|
||||
ToolsIcon,
|
||||
TrashIcon,
|
||||
UserIcon,
|
||||
@@ -83,6 +84,38 @@ export const copyId = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
function generateRandomText() {
|
||||
const characters =
|
||||
"abcdefghijklmno pqrstuvwxyzABCDEFGHIJKL MNOPQRSTUVWXYZ 0123456789\n";
|
||||
let text = "";
|
||||
for (let i = 0; i < Math.floor(Math.random() * 10) + 1; i++) {
|
||||
text += characters.charAt(Math.floor(Math.random() * characters.length));
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
export const startTyping = createAction({
|
||||
name: "Start automatic typing",
|
||||
icon: <EditIcon />,
|
||||
section: DeveloperSection,
|
||||
visible: ({ activeDocumentId }) =>
|
||||
!!activeDocumentId && env.ENVIRONMENT === "development",
|
||||
perform: () => {
|
||||
const intervalId = setInterval(() => {
|
||||
const text = generateRandomText();
|
||||
document.execCommand("insertText", false, text);
|
||||
}, 250);
|
||||
|
||||
window.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape") {
|
||||
intervalId && clearInterval(intervalId);
|
||||
}
|
||||
});
|
||||
|
||||
toast.info("Automatic typing started, press Escape to stop");
|
||||
},
|
||||
});
|
||||
|
||||
export const clearIndexedDB = createAction({
|
||||
name: ({ t }) => t("Clear IndexedDB cache"),
|
||||
icon: <TrashIcon />,
|
||||
@@ -169,6 +202,7 @@ export const developer = createAction({
|
||||
createToast,
|
||||
createTestUsers,
|
||||
clearIndexedDB,
|
||||
startTyping,
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -125,6 +125,20 @@ export const createDocument = createAction({
|
||||
}),
|
||||
});
|
||||
|
||||
export const createDraftDocument = createAction({
|
||||
name: ({ t }) => t("New draft"),
|
||||
analyticsName: "New document",
|
||||
section: DocumentSection,
|
||||
icon: <NewDocumentIcon />,
|
||||
keywords: "create document",
|
||||
visible: ({ currentTeamId, stores }) =>
|
||||
!!currentTeamId && stores.policies.abilities(currentTeamId).createDocument,
|
||||
perform: ({ sidebarContext }) =>
|
||||
history.push(newDocumentPath(), {
|
||||
sidebarContext,
|
||||
}),
|
||||
});
|
||||
|
||||
export const createDocumentFromTemplate = createAction({
|
||||
name: ({ t }) => t("New from template"),
|
||||
analyticsName: "New document",
|
||||
@@ -319,6 +333,7 @@ export const subscribeDocument = createAction({
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
|
||||
return (
|
||||
!document?.collection?.isSubscribed &&
|
||||
!document?.isSubscribed &&
|
||||
stores.policies.abilities(activeDocumentId).subscribe
|
||||
);
|
||||
@@ -347,8 +362,9 @@ export const unsubscribeDocument = createAction({
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
|
||||
return (
|
||||
!!document?.isSubscribed &&
|
||||
stores.policies.abilities(activeDocumentId).unsubscribe
|
||||
!!document?.collection?.isSubscribed ||
|
||||
(!!document?.isSubscribed &&
|
||||
stores.policies.abilities(activeDocumentId).unsubscribe)
|
||||
);
|
||||
},
|
||||
perform: async ({ activeDocumentId, stores, currentUserId, t }) => {
|
||||
@@ -358,7 +374,7 @@ export const unsubscribeDocument = createAction({
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
|
||||
await document?.unsubscribe(currentUserId);
|
||||
await document?.unsubscribe();
|
||||
|
||||
toast.success(t("Unsubscribed from document notifications"));
|
||||
},
|
||||
@@ -1179,6 +1195,8 @@ export const rootDocumentActions = [
|
||||
openDocument,
|
||||
archiveDocument,
|
||||
createDocument,
|
||||
createDraftDocument,
|
||||
createNestedDocument,
|
||||
createTemplateFromDocument,
|
||||
deleteDocument,
|
||||
importDocument,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getLuminance } from "polished";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import Flex from "~/components/Flex";
|
||||
@@ -15,7 +16,10 @@ const Initials = styled(Flex)<{
|
||||
border-radius: 50%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: ${s("white75")};
|
||||
color: ${(props) =>
|
||||
getLuminance(props.color ?? props.theme.textTertiary) > 0.5
|
||||
? s("black50")
|
||||
: s("white75")};
|
||||
background-color: ${(props) => props.color ?? props.theme.textTertiary};
|
||||
width: ${(props) => props.size}px;
|
||||
height: ${(props) => props.size}px;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { toast } from "sonner";
|
||||
import styled from "styled-components";
|
||||
import { richExtensions } from "@shared/editor/nodes";
|
||||
import { s } from "@shared/styles";
|
||||
import { CollectionValidation } from "@shared/validations";
|
||||
import Collection from "~/models/Collection";
|
||||
import Editor from "~/components/Editor";
|
||||
import LoadingIndicator from "~/components/LoadingIndicator";
|
||||
@@ -61,7 +62,7 @@ function CollectionDescription({ collection }: Props) {
|
||||
onChange={handleSave}
|
||||
placeholder={`${t("Add a description")}…`}
|
||||
extensions={extensions}
|
||||
maxLength={1000}
|
||||
maxLength={CollectionValidation.maxDescriptionLength}
|
||||
canUpdate={can.update}
|
||||
readOnly={!can.update}
|
||||
editorStyle={editorStyle}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
MenuHeading,
|
||||
MenuItem as TMenuItem,
|
||||
} from "~/types";
|
||||
import Tooltip from "../Tooltip";
|
||||
import Header from "./Header";
|
||||
import MenuItem, { MenuAnchor } from "./MenuItem";
|
||||
import MouseSafeArea from "./MouseSafeArea";
|
||||
@@ -167,7 +168,7 @@ function Template({ items, actions, context, showIcons, ...menu }: Props) {
|
||||
}
|
||||
|
||||
if (item.type === "button") {
|
||||
return (
|
||||
const menuItem = (
|
||||
<MenuItem
|
||||
as="button"
|
||||
id={`${item.title}-${index}`}
|
||||
@@ -182,6 +183,14 @@ function Template({ items, actions, context, showIcons, ...menu }: Props) {
|
||||
{item.title}
|
||||
</MenuItem>
|
||||
);
|
||||
|
||||
return item.tooltip ? (
|
||||
<Tooltip content={item.tooltip} placement={"bottom"}>
|
||||
<div>{menuItem}</div>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<>{menuItem}</>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === "submenu") {
|
||||
|
||||
@@ -15,7 +15,7 @@ import scrollIntoView from "scroll-into-view-if-needed";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
import { NavigationNode, NavigationNodeType } from "@shared/types";
|
||||
import { isModKey } from "@shared/utils/keyboard";
|
||||
import DocumentExplorerNode from "~/components/DocumentExplorerNode";
|
||||
import DocumentExplorerSearchResult from "~/components/DocumentExplorerSearchResult";
|
||||
@@ -78,6 +78,10 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
|
||||
const VERTICAL_PADDING = 6;
|
||||
const HORIZONTAL_PADDING = 24;
|
||||
|
||||
const recentlyViewedItemIds = documents.recentlyViewed
|
||||
.slice(0, 5)
|
||||
.map((item) => item.id);
|
||||
|
||||
const searchIndex = React.useMemo(
|
||||
() =>
|
||||
new FuzzySearch(items, ["title"], {
|
||||
@@ -126,11 +130,18 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
|
||||
return searchTerm
|
||||
? searchIndex.search(searchTerm)
|
||||
: items
|
||||
.filter((item) => item.type === "collection")
|
||||
.filter((item) => recentlyViewedItemIds.includes(item.id))
|
||||
.concat(
|
||||
items.filter((item) => item.type === NavigationNodeType.Collection)
|
||||
)
|
||||
.flatMap(includeDescendants);
|
||||
}
|
||||
|
||||
const nodes = getNodes();
|
||||
const baseDepth = nodes.reduce(
|
||||
(min, node) => (node.depth ? Math.min(min, node.depth) : min),
|
||||
Infinity
|
||||
);
|
||||
|
||||
const scrollNodeIntoView = React.useCallback(
|
||||
(node: number) => {
|
||||
@@ -304,7 +315,7 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
|
||||
expanded={isExpanded(index)}
|
||||
icon={renderedIcon}
|
||||
title={title}
|
||||
depth={node.depth as number}
|
||||
depth={(node.depth ?? 0) - baseDepth}
|
||||
hasChildren={hasChildren(index)}
|
||||
ref={itemRefs[index]}
|
||||
/>
|
||||
|
||||
@@ -41,9 +41,9 @@ function DocumentExplorerNode(
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const OFFSET = 12;
|
||||
const ICON_SIZE = 24;
|
||||
const DISCLOSURE = 20;
|
||||
|
||||
const width = depth ? depth * ICON_SIZE + OFFSET : ICON_SIZE;
|
||||
const width = depth ? depth * DISCLOSURE + OFFSET : DISCLOSURE;
|
||||
|
||||
return (
|
||||
<Node
|
||||
|
||||
@@ -199,15 +199,14 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
placeholder={props.placeholder || ""}
|
||||
defaultValue={props.defaultValue || ""}
|
||||
/>
|
||||
{props.editorStyle?.paddingBottom &&
|
||||
(!props.readOnly || props.shareId) && (
|
||||
<ClickablePadding
|
||||
onClick={props.readOnly ? undefined : focusAtEnd}
|
||||
onDrop={props.readOnly ? undefined : handleDrop}
|
||||
onDragOver={props.readOnly ? undefined : handleDragOver}
|
||||
minHeight={props.editorStyle.paddingBottom}
|
||||
/>
|
||||
)}
|
||||
{props.editorStyle?.paddingBottom && !props.readOnly && (
|
||||
<ClickablePadding
|
||||
onClick={props.readOnly ? undefined : focusAtEnd}
|
||||
onDrop={props.readOnly ? undefined : handleDrop}
|
||||
onDragOver={props.readOnly ? undefined : handleDragOver}
|
||||
minHeight={props.editorStyle.paddingBottom}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
@@ -7,6 +7,9 @@ import {
|
||||
PublishIcon,
|
||||
MoveIcon,
|
||||
UnpublishIcon,
|
||||
RestoreIcon,
|
||||
UserIcon,
|
||||
CrossIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -14,32 +17,61 @@ import { useLocation } from "react-router-dom";
|
||||
import styled, { css } from "styled-components";
|
||||
import EventBoundary from "@shared/components/EventBoundary";
|
||||
import { s, hover } from "@shared/styles";
|
||||
import { RevisionHelper } from "@shared/utils/RevisionHelper";
|
||||
import Document from "~/models/Document";
|
||||
import Event from "~/models/Event";
|
||||
import { Avatar, AvatarSize } from "~/components/Avatar";
|
||||
import Item, { Actions, Props as ItemProps } from "~/components/List/Item";
|
||||
import Item, { Actions } from "~/components/List/Item";
|
||||
import Time from "~/components/Time";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import RevisionMenu from "~/menus/RevisionMenu";
|
||||
import Logger from "~/utils/Logger";
|
||||
import { documentHistoryPath } from "~/utils/routeHelpers";
|
||||
import Text from "./Text";
|
||||
|
||||
export type RevisionEvent = {
|
||||
name: "revisions.create";
|
||||
latest: boolean;
|
||||
};
|
||||
|
||||
export type DocumentEvent = {
|
||||
name:
|
||||
| "documents.publish"
|
||||
| "documents.unpublish"
|
||||
| "documents.archive"
|
||||
| "documents.unarchive"
|
||||
| "documents.delete"
|
||||
| "documents.restore"
|
||||
| "documents.add_user"
|
||||
| "documents.remove_user"
|
||||
| "documents.move";
|
||||
userId: string;
|
||||
};
|
||||
|
||||
export type Event = { id: string; actorId: string; createdAt: string } & (
|
||||
| RevisionEvent
|
||||
| DocumentEvent
|
||||
);
|
||||
|
||||
type Props = {
|
||||
document: Document;
|
||||
event: Event<Document>;
|
||||
latest?: boolean;
|
||||
event: Event;
|
||||
};
|
||||
|
||||
const EventListItem = ({ event, latest, document, ...rest }: Props) => {
|
||||
const EventListItem = ({ event, document, ...rest }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { revisions } = useStores();
|
||||
const { revisions, users } = useStores();
|
||||
const actor = "actorId" in event ? users.get(event.actorId) : undefined;
|
||||
const user = "userId" in event ? users.get(event.userId) : undefined;
|
||||
const location = useLocation();
|
||||
const sidebarContext = useLocationSidebarContext();
|
||||
const revisionLoadedRef = React.useRef(false);
|
||||
const opts = {
|
||||
userName: event.actor.name,
|
||||
userName: actor?.name,
|
||||
};
|
||||
const isRevision = event.name === "revisions.create";
|
||||
const isDerivedFromDocument =
|
||||
event.id === RevisionHelper.latestId(document.id);
|
||||
let meta, icon, to: LocationDescriptor | undefined;
|
||||
|
||||
const ref = React.useRef<HTMLAnchorElement>(null);
|
||||
@@ -50,23 +82,32 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
|
||||
};
|
||||
|
||||
const prefetchRevision = async () => {
|
||||
if (event.name === "revisions.create" && event.modelId) {
|
||||
await revisions.fetch(event.modelId);
|
||||
if (
|
||||
!document.isDeleted &&
|
||||
event.name === "revisions.create" &&
|
||||
!isDerivedFromDocument &&
|
||||
!revisionLoadedRef.current
|
||||
) {
|
||||
await revisions.fetch(event.id, { force: true });
|
||||
revisionLoadedRef.current = true;
|
||||
}
|
||||
};
|
||||
|
||||
switch (event.name) {
|
||||
case "revisions.create":
|
||||
icon = <EditIcon size={16} />;
|
||||
meta = latest ? (
|
||||
meta = event.latest ? (
|
||||
<>
|
||||
{t("Current version")} · {event.actor.name}
|
||||
{t("Current version")} · {actor?.name}
|
||||
</>
|
||||
) : (
|
||||
t("{{userName}} edited", opts)
|
||||
);
|
||||
to = {
|
||||
pathname: documentHistoryPath(document, event.modelId || "latest"),
|
||||
pathname: documentHistoryPath(
|
||||
document,
|
||||
isDerivedFromDocument ? "latest" : event.id
|
||||
),
|
||||
state: {
|
||||
sidebarContext,
|
||||
retainScrollPosition: true,
|
||||
@@ -75,47 +116,51 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
|
||||
break;
|
||||
|
||||
case "documents.archive":
|
||||
icon = <ArchiveIcon size={16} />;
|
||||
icon = <ArchiveIcon />;
|
||||
meta = t("{{userName}} archived", opts);
|
||||
break;
|
||||
|
||||
case "documents.unarchive":
|
||||
icon = <RestoreIcon />;
|
||||
meta = t("{{userName}} restored", opts);
|
||||
break;
|
||||
|
||||
case "documents.delete":
|
||||
icon = <TrashIcon size={16} />;
|
||||
icon = <TrashIcon />;
|
||||
meta = t("{{userName}} deleted", opts);
|
||||
break;
|
||||
case "documents.add_user":
|
||||
icon = <UserIcon />;
|
||||
meta = t("{{userName}} added {{addedUserName}}", {
|
||||
...opts,
|
||||
addedUserName: event.user?.name ?? t("a user"),
|
||||
addedUserName: user?.name ?? t("a user"),
|
||||
});
|
||||
break;
|
||||
case "documents.remove_user":
|
||||
icon = <CrossIcon />;
|
||||
meta = t("{{userName}} removed {{removedUserName}}", {
|
||||
...opts,
|
||||
removedUserName: event.user?.name ?? t("a user"),
|
||||
removedUserName: user?.name ?? t("a user"),
|
||||
});
|
||||
break;
|
||||
|
||||
case "documents.restore":
|
||||
icon = <RestoreIcon />;
|
||||
meta = t("{{userName}} moved from trash", opts);
|
||||
break;
|
||||
|
||||
case "documents.publish":
|
||||
icon = <PublishIcon size={16} />;
|
||||
icon = <PublishIcon />;
|
||||
meta = t("{{userName}} published", opts);
|
||||
break;
|
||||
|
||||
case "documents.unpublish":
|
||||
icon = <UnpublishIcon size={16} />;
|
||||
icon = <UnpublishIcon />;
|
||||
meta = t("{{userName}} unpublished", opts);
|
||||
break;
|
||||
|
||||
case "documents.move":
|
||||
icon = <MoveIcon size={16} />;
|
||||
icon = <MoveIcon />;
|
||||
meta = t("{{userName}} moved", opts);
|
||||
break;
|
||||
|
||||
@@ -136,8 +181,8 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
|
||||
to = undefined;
|
||||
}
|
||||
|
||||
return (
|
||||
<BaseItem
|
||||
return event.name === "revisions.create" ? (
|
||||
<RevisionItem
|
||||
small
|
||||
exact
|
||||
to={to}
|
||||
@@ -153,17 +198,12 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
|
||||
onClick={handleTimeClick}
|
||||
/>
|
||||
}
|
||||
image={<Avatar model={event.actor} size={AvatarSize.Large} />}
|
||||
subtitle={
|
||||
<Subtitle>
|
||||
{icon}
|
||||
{meta}
|
||||
</Subtitle>
|
||||
}
|
||||
image={<Avatar model={actor} size={AvatarSize.Large} />}
|
||||
subtitle={meta}
|
||||
actions={
|
||||
isRevision && isActive && event.modelId && !latest ? (
|
||||
isRevision && isActive && !event.latest ? (
|
||||
<StyledEventBoundary>
|
||||
<RevisionMenu document={document} revisionId={event.modelId} />
|
||||
<RevisionMenu document={document} revisionId={event.id} />
|
||||
</StyledEventBoundary>
|
||||
) : undefined
|
||||
}
|
||||
@@ -171,63 +211,100 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
|
||||
ref={ref}
|
||||
{...rest}
|
||||
/>
|
||||
) : (
|
||||
<EventItem>
|
||||
<IconWrapper size="xsmall" type="secondary">
|
||||
{icon}
|
||||
</IconWrapper>
|
||||
<Text size="xsmall" type="secondary">
|
||||
{meta} ·{" "}
|
||||
<Time dateTime={event.createdAt} relative shorten addSuffix />
|
||||
</Text>
|
||||
</EventItem>
|
||||
);
|
||||
};
|
||||
|
||||
const BaseItem = React.forwardRef(function _BaseItem(
|
||||
{ to, ...rest }: ItemProps,
|
||||
ref?: React.Ref<HTMLAnchorElement>
|
||||
) {
|
||||
return <ListItem to={to} ref={ref} {...rest} />;
|
||||
});
|
||||
const lineStyle = css`
|
||||
&::before {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
left: 22px;
|
||||
width: 1px;
|
||||
height: calc(50% - 14px + 8px);
|
||||
background: ${s("divider")};
|
||||
mix-blend-mode: multiply;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
&:first-child::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&:nth-child(2)::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: calc(50% + 14px);
|
||||
left: 22px;
|
||||
width: 1px;
|
||||
height: calc(50% - 14px);
|
||||
background: ${s("divider")};
|
||||
mix-blend-mode: multiply;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
&:last-child::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
h3 + &::before {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const IconWrapper = styled(Text)`
|
||||
height: 24px;
|
||||
`;
|
||||
|
||||
const EventItem = styled.li`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
list-style: none;
|
||||
margin: 8px 0;
|
||||
padding: 4px 10px;
|
||||
white-space: nowrap;
|
||||
position: relative;
|
||||
|
||||
time {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
${lineStyle}
|
||||
`;
|
||||
|
||||
const StyledEventBoundary = styled(EventBoundary)`
|
||||
height: 24px;
|
||||
`;
|
||||
|
||||
const Subtitle = styled.span`
|
||||
svg {
|
||||
margin: -3px;
|
||||
margin-right: 2px;
|
||||
}
|
||||
`;
|
||||
|
||||
const ItemStyle = css`
|
||||
const RevisionItem = styled(Item)`
|
||||
border: 0;
|
||||
position: relative;
|
||||
margin: 8px 0;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
|
||||
img {
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
left: 23px;
|
||||
width: 2px;
|
||||
height: calc(100% + 8px);
|
||||
background: ${s("textSecondary")};
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
&:nth-child(2)::before {
|
||||
height: 50%;
|
||||
top: auto;
|
||||
bottom: -4px;
|
||||
}
|
||||
|
||||
&:last-child::before {
|
||||
height: 50%;
|
||||
}
|
||||
|
||||
&:first-child:last-child::before {
|
||||
display: none;
|
||||
}
|
||||
${lineStyle}
|
||||
|
||||
${Actions} {
|
||||
opacity: 0.5;
|
||||
@@ -238,8 +315,4 @@ const ItemStyle = css`
|
||||
}
|
||||
`;
|
||||
|
||||
const ListItem = styled(Item)`
|
||||
${ItemStyle}
|
||||
`;
|
||||
|
||||
export default observer(EventListItem);
|
||||
|
||||
@@ -27,7 +27,7 @@ const HoverPreviewLink = React.forwardRef(function _HoverPreviewLink(
|
||||
return (
|
||||
<Preview as="a" href={url} target="_blank" rel="noopener noreferrer">
|
||||
<Flex column ref={ref}>
|
||||
{thumbnailUrl ? <Thumbnail src={thumbnailUrl} alt={""} /> : null}
|
||||
{thumbnailUrl ? <Thumbnail src={thumbnailUrl} alt="" /> : null}
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Flex column>
|
||||
|
||||
@@ -176,6 +176,7 @@ function Input(
|
||||
if (ev.key === "Enter" && ev.metaKey) {
|
||||
if (props.onRequestSubmit) {
|
||||
props.onRequestSubmit(ev);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,10 +231,11 @@ function Input(
|
||||
])}
|
||||
onBlur={handleBlur}
|
||||
onFocus={handleFocus}
|
||||
onKeyDown={handleKeyDown}
|
||||
hasIcon={!!icon}
|
||||
hasPrefix={!!prefix}
|
||||
{...rest}
|
||||
// set it after "rest" to override "onKeyDown" from prop.
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
) : (
|
||||
<NativeInput
|
||||
@@ -243,11 +245,12 @@ function Input(
|
||||
])}
|
||||
onBlur={handleBlur}
|
||||
onFocus={handleFocus}
|
||||
onKeyDown={handleKeyDown}
|
||||
hasIcon={!!icon}
|
||||
hasPrefix={!!prefix}
|
||||
type={type}
|
||||
{...rest}
|
||||
// set it after "rest" to override "onKeyDown" from prop.
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
)}
|
||||
{children}
|
||||
|
||||
@@ -48,7 +48,8 @@ export type Props = Omit<ButtonProps<any>, "onChange"> & {
|
||||
options: Option[];
|
||||
/** @deprecated Removing soon, do not use. */
|
||||
note?: React.ReactNode;
|
||||
onChange?: (value: string | null) => void;
|
||||
/** Callback function that is called when the value changes. Return false to cancel the change. */
|
||||
onChange?: (value: string | null) => void | Promise<boolean | void>;
|
||||
style?: React.CSSProperties;
|
||||
/**
|
||||
* Set to true if this component is rendered inside a Modal.
|
||||
@@ -165,9 +166,18 @@ const InputSelect = (props: Props, ref: React.RefObject<InputSelectRef>) => {
|
||||
if (previousValue.current === select.selectedValue) {
|
||||
return;
|
||||
}
|
||||
const previous = previousValue.current;
|
||||
previousValue.current = select.selectedValue;
|
||||
|
||||
onChange?.(select.selectedValue);
|
||||
const response = onChange?.(select.selectedValue);
|
||||
if (response && response instanceof Promise) {
|
||||
void response.then((success) => {
|
||||
if (success === false) {
|
||||
select.selectedValue = previous;
|
||||
select.setSelectedValue(previous);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [onChange, select.selectedValue]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import Document from "~/models/Document";
|
||||
import Event from "~/models/Event";
|
||||
import PaginatedList from "~/components/PaginatedList";
|
||||
import EventListItem from "./EventListItem";
|
||||
import EventListItem, { type Event } from "./EventListItem";
|
||||
|
||||
type Props = {
|
||||
events: Event<Document>[];
|
||||
events: Event[];
|
||||
document: Document;
|
||||
fetch: (
|
||||
options: Record<string, any> | undefined
|
||||
) => Promise<Event<Document>[]>;
|
||||
fetch: (options: Record<string, any> | undefined) => Promise<Event[]>;
|
||||
options?: Record<string, any>;
|
||||
heading?: React.ReactNode;
|
||||
empty?: React.ReactNode;
|
||||
@@ -32,13 +29,8 @@ const PaginatedEventList = React.memo<Props>(function PaginatedEventList({
|
||||
heading={heading}
|
||||
fetch={fetch}
|
||||
options={options}
|
||||
renderItem={(item: Event<Document>, index) => (
|
||||
<EventListItem
|
||||
key={item.id}
|
||||
event={item}
|
||||
document={document}
|
||||
latest={index === 0}
|
||||
/>
|
||||
renderItem={(item: Event) => (
|
||||
<EventListItem key={item.id} event={item} document={document} />
|
||||
)}
|
||||
renderHeading={(name) => <Heading>{name}</Heading>}
|
||||
{...rest}
|
||||
|
||||
@@ -60,7 +60,7 @@ class PaginatedList<T extends PaginatedItem> extends React.PureComponent<
|
||||
fetchCounter = 0;
|
||||
|
||||
@observable
|
||||
renderCount = 15;
|
||||
renderCount = Pagination.defaultLimit;
|
||||
|
||||
@observable
|
||||
offset = 0;
|
||||
@@ -108,13 +108,16 @@ class PaginatedList<T extends PaginatedItem> extends React.PureComponent<
|
||||
...this.props.options,
|
||||
});
|
||||
|
||||
if (this.offset !== 0) {
|
||||
this.renderCount += limit;
|
||||
}
|
||||
|
||||
if (results && (results.length === 0 || results.length < limit)) {
|
||||
this.allowLoadMore = false;
|
||||
} else {
|
||||
this.offset += limit;
|
||||
}
|
||||
|
||||
this.renderCount += limit;
|
||||
this.isFetchingInitial = false;
|
||||
} catch (err) {
|
||||
this.error = err;
|
||||
@@ -248,7 +251,9 @@ class PaginatedList<T extends PaginatedItem> extends React.PureComponent<
|
||||
}}
|
||||
</ArrowKeyNavigation>
|
||||
{this.allowLoadMore && (
|
||||
<Waypoint key={this.renderCount} onEnter={this.loadMoreResults} />
|
||||
<div style={{ height: "1px" }}>
|
||||
<Waypoint key={this.renderCount} onEnter={this.loadMoreResults} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { observer } from "mobx-react";
|
||||
import { UserIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import Squircle from "@shared/components/Squircle";
|
||||
import { CollectionPermission } from "@shared/types";
|
||||
@@ -167,18 +168,24 @@ export const AccessControlList = observer(
|
||||
| CollectionPermission
|
||||
| typeof EmptySelectValue
|
||||
) => {
|
||||
if (permission === EmptySelectValue) {
|
||||
await groupMemberships.delete({
|
||||
collectionId: collection.id,
|
||||
groupId: membership.groupId,
|
||||
});
|
||||
} else {
|
||||
await groupMemberships.create({
|
||||
collectionId: collection.id,
|
||||
groupId: membership.groupId,
|
||||
permission,
|
||||
});
|
||||
try {
|
||||
if (permission === EmptySelectValue) {
|
||||
await groupMemberships.delete({
|
||||
collectionId: collection.id,
|
||||
groupId: membership.groupId,
|
||||
});
|
||||
} else {
|
||||
await groupMemberships.create({
|
||||
collectionId: collection.id,
|
||||
groupId: membership.groupId,
|
||||
permission,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}}
|
||||
disabled={!can.update}
|
||||
value={membership.permission}
|
||||
@@ -215,18 +222,24 @@ export const AccessControlList = observer(
|
||||
| CollectionPermission
|
||||
| typeof EmptySelectValue
|
||||
) => {
|
||||
if (permission === EmptySelectValue) {
|
||||
await memberships.delete({
|
||||
collectionId: collection.id,
|
||||
userId: membership.userId,
|
||||
});
|
||||
} else {
|
||||
await memberships.create({
|
||||
collectionId: collection.id,
|
||||
userId: membership.userId,
|
||||
permission,
|
||||
});
|
||||
try {
|
||||
if (permission === EmptySelectValue) {
|
||||
await memberships.delete({
|
||||
collectionId: collection.id,
|
||||
userId: membership.userId,
|
||||
});
|
||||
} else {
|
||||
await memberships.create({
|
||||
collectionId: collection.id,
|
||||
userId: membership.userId,
|
||||
permission,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}}
|
||||
disabled={!can.update}
|
||||
value={membership.permission}
|
||||
|
||||
@@ -1,26 +1,25 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { DraftsIcon, SearchIcon, HomeIcon, SidebarIcon } from "outline-icons";
|
||||
import { SearchIcon, HomeIcon, SidebarIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { DndProvider } from "react-dnd";
|
||||
import { HTML5Backend } from "react-dnd-html5-backend";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { metaDisplay } from "@shared/utils/keyboard";
|
||||
import Flex from "~/components/Flex";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import Text from "~/components/Text";
|
||||
import { inviteUser } from "~/actions/definitions/users";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import OrganizationMenu from "~/menus/OrganizationMenu";
|
||||
import { homePath, draftsPath, searchPath } from "~/utils/routeHelpers";
|
||||
import { homePath, searchPath } from "~/utils/routeHelpers";
|
||||
import TeamLogo from "../TeamLogo";
|
||||
import Tooltip from "../Tooltip";
|
||||
import Sidebar from "./Sidebar";
|
||||
import ArchiveLink from "./components/ArchiveLink";
|
||||
import Collections from "./components/Collections";
|
||||
import { DraftsLink } from "./components/DraftsLink";
|
||||
import DragPlaceholder from "./components/DragPlaceholder";
|
||||
import HistoryNavigation from "./components/HistoryNavigation";
|
||||
import Section from "./components/Section";
|
||||
@@ -107,24 +106,7 @@ function AppSidebar() {
|
||||
label={t("Search")}
|
||||
exact={false}
|
||||
/>
|
||||
{can.createDocument && (
|
||||
<SidebarLink
|
||||
to={draftsPath()}
|
||||
icon={<DraftsIcon />}
|
||||
label={
|
||||
<Flex align="center" justify="space-between">
|
||||
{t("Drafts")}
|
||||
{documents.totalDrafts > 0 ? (
|
||||
<Drafts size="xsmall" type="tertiary">
|
||||
{documents.totalDrafts > 25
|
||||
? "25+"
|
||||
: documents.totalDrafts}
|
||||
</Drafts>
|
||||
) : null}
|
||||
</Flex>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{can.createDocument && <DraftsLink />}
|
||||
</Section>
|
||||
</Overflow>
|
||||
<Scrollable flex shadow>
|
||||
@@ -158,8 +140,4 @@ const Overflow = styled.div`
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const Drafts = styled(Text)`
|
||||
margin: 0 4px;
|
||||
`;
|
||||
|
||||
export default observer(AppSidebar);
|
||||
|
||||
@@ -14,6 +14,7 @@ import { ArchivedCollectionLink } from "./ArchivedCollectionLink";
|
||||
import { StyledError } from "./Collections";
|
||||
import PlaceholderCollections from "./PlaceholderCollections";
|
||||
import Relative from "./Relative";
|
||||
import SidebarContext from "./SidebarContext";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
|
||||
function ArchiveLink() {
|
||||
@@ -64,38 +65,40 @@ function ArchiveLink() {
|
||||
useDropToArchive();
|
||||
|
||||
return (
|
||||
<Flex column>
|
||||
<div ref={dropToArchiveRef}>
|
||||
<SidebarLink
|
||||
to={archivePath()}
|
||||
icon={<ArchiveIcon open={isOverArchiveSection && isDragging} />}
|
||||
exact={false}
|
||||
label={t("Archive")}
|
||||
isActiveDrop={isOverArchiveSection && isDragging}
|
||||
depth={0}
|
||||
expanded={disclosure ? expanded : undefined}
|
||||
onDisclosureClick={handleDisclosureClick}
|
||||
onClick={handleClick}
|
||||
/>
|
||||
</div>
|
||||
{expanded === true ? (
|
||||
<Relative>
|
||||
<PaginatedList
|
||||
aria-label={t("Archived collections")}
|
||||
items={collections.archived}
|
||||
loading={<PlaceholderCollections />}
|
||||
renderError={(props) => <StyledError {...props} />}
|
||||
renderItem={(item: Collection) => (
|
||||
<ArchivedCollectionLink
|
||||
key={item.id}
|
||||
depth={1}
|
||||
collection={item}
|
||||
/>
|
||||
)}
|
||||
<SidebarContext.Provider value="archive">
|
||||
<Flex column>
|
||||
<div ref={dropToArchiveRef}>
|
||||
<SidebarLink
|
||||
to={archivePath()}
|
||||
icon={<ArchiveIcon open={isOverArchiveSection && isDragging} />}
|
||||
exact={false}
|
||||
label={t("Archive")}
|
||||
isActiveDrop={isOverArchiveSection && isDragging}
|
||||
depth={0}
|
||||
expanded={disclosure ? expanded : undefined}
|
||||
onDisclosureClick={handleDisclosureClick}
|
||||
onClick={handleClick}
|
||||
/>
|
||||
</Relative>
|
||||
) : null}
|
||||
</Flex>
|
||||
</div>
|
||||
{expanded === true ? (
|
||||
<Relative>
|
||||
<PaginatedList
|
||||
aria-label={t("Archived collections")}
|
||||
items={collections.archived}
|
||||
loading={<PlaceholderCollections />}
|
||||
renderError={(props) => <StyledError {...props} />}
|
||||
renderItem={(item: Collection) => (
|
||||
<ArchivedCollectionLink
|
||||
key={item.id}
|
||||
depth={1}
|
||||
collection={item}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Relative>
|
||||
) : null}
|
||||
</Flex>
|
||||
</SidebarContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -101,9 +101,12 @@ const CollectionLink: React.FC<Props> = ({
|
||||
collection?.addDocument(newDocument);
|
||||
|
||||
closeAddingNewChild();
|
||||
history.replace(documentEditPath(newDocument));
|
||||
history.push({
|
||||
pathname: documentEditPath(newDocument),
|
||||
state: { sidebarContext },
|
||||
});
|
||||
},
|
||||
[user, closeAddingNewChild, history, collection, documents]
|
||||
[user, sidebarContext, closeAddingNewChild, history, collection, documents]
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -144,16 +147,18 @@ const CollectionLink: React.FC<Props> = ({
|
||||
!isEditing &&
|
||||
!isDraggingAnyCollection && (
|
||||
<Fade>
|
||||
<NudeButton
|
||||
tooltip={{ content: t("New doc"), delay: 500 }}
|
||||
onClick={(ev) => {
|
||||
ev.preventDefault();
|
||||
setIsAddingNewChild();
|
||||
handleExpand();
|
||||
}}
|
||||
>
|
||||
<PlusIcon />
|
||||
</NudeButton>
|
||||
{can.createDocument && (
|
||||
<NudeButton
|
||||
tooltip={{ content: t("New doc"), delay: 500 }}
|
||||
onClick={(ev) => {
|
||||
ev.preventDefault();
|
||||
setIsAddingNewChild();
|
||||
handleExpand();
|
||||
}}
|
||||
>
|
||||
<PlusIcon />
|
||||
</NudeButton>
|
||||
)}
|
||||
<CollectionMenu
|
||||
collection={collection}
|
||||
onRename={handleRename}
|
||||
|
||||
@@ -240,9 +240,21 @@ function InnerDocumentLink(
|
||||
collection?.addDocument(newDocument, node.id);
|
||||
|
||||
closeAddingNewChild();
|
||||
history.replace(documentEditPath(newDocument));
|
||||
history.push({
|
||||
pathname: documentEditPath(newDocument),
|
||||
state: { sidebarContext },
|
||||
});
|
||||
},
|
||||
[documents, collection, user, node, doc, history, closeAddingNewChild]
|
||||
[
|
||||
documents,
|
||||
collection,
|
||||
sidebarContext,
|
||||
user,
|
||||
node,
|
||||
doc,
|
||||
history,
|
||||
closeAddingNewChild,
|
||||
]
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { DraftsIcon } from "outline-icons";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import Flex from "~/components/Flex";
|
||||
import Text from "~/components/Text";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { draftsPath } from "~/utils/routeHelpers";
|
||||
import { useDropToUnpublish } from "../hooks/useDragAndDrop";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
|
||||
export const DraftsLink = observer(() => {
|
||||
const { t } = useTranslation();
|
||||
const { documents } = useStores();
|
||||
const [{ isOver, canDrop }, dropRef] = useDropToUnpublish();
|
||||
|
||||
return (
|
||||
<div ref={dropRef}>
|
||||
<SidebarLink
|
||||
to={draftsPath()}
|
||||
icon={<DraftsIcon />}
|
||||
label={
|
||||
<Flex align="center" justify="space-between">
|
||||
{t("Drafts")}
|
||||
{documents.totalDrafts > 0 ? (
|
||||
<Drafts size="xsmall" type="tertiary">
|
||||
{documents.totalDrafts > 25 ? "25+" : documents.totalDrafts}
|
||||
</Drafts>
|
||||
) : null}
|
||||
</Flex>
|
||||
}
|
||||
isActiveDrop={isOver && canDrop}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const Drafts = styled(Text)`
|
||||
margin: 0 4px;
|
||||
`;
|
||||
@@ -5,6 +5,7 @@ import User from "~/models/User";
|
||||
export type SidebarContextType =
|
||||
| "collections"
|
||||
| "shared"
|
||||
| "archive"
|
||||
| `group-${string}`
|
||||
| `starred-${string}`
|
||||
| undefined;
|
||||
@@ -41,7 +42,7 @@ export const determineSidebarContext = ({
|
||||
}
|
||||
|
||||
if (document.collection) {
|
||||
return "collections";
|
||||
return document.collection.isArchived ? "archive" : "collections";
|
||||
} else if (
|
||||
user.documentMemberships.find((m) => m.documentId === document.id)
|
||||
) {
|
||||
|
||||
@@ -86,6 +86,11 @@ function StarredLink({ star }: Props) {
|
||||
[]
|
||||
);
|
||||
|
||||
const handlePrefetch = React.useCallback(
|
||||
() => documentId && documents.prefetchDocument(documentId),
|
||||
[documents, documentId]
|
||||
);
|
||||
|
||||
const getIndex = () => {
|
||||
const next = star?.next();
|
||||
return fractionalIndex(star?.index || null, next?.index || null);
|
||||
@@ -142,6 +147,7 @@ function StarredLink({ star }: Props) {
|
||||
}}
|
||||
expanded={hasChildDocuments && !isDragging ? expanded : undefined}
|
||||
onDisclosureClick={handleDisclosureClick}
|
||||
onClickIntent={handlePrefetch}
|
||||
icon={icon}
|
||||
isActive={(
|
||||
match,
|
||||
@@ -172,6 +178,7 @@ function StarredLink({ star }: Props) {
|
||||
node={node}
|
||||
collection={collection}
|
||||
activeDocument={documents.active}
|
||||
prefetchDocument={documents.prefetchDocument}
|
||||
isDraft={node.isDraft}
|
||||
depth={2}
|
||||
index={index}
|
||||
|
||||
@@ -586,3 +586,45 @@ export function useDropToArchive() {
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDropToUnpublish() {
|
||||
const { t } = useTranslation();
|
||||
const { policies, documents } = useStores();
|
||||
|
||||
return useDrop<
|
||||
DragObject,
|
||||
Promise<void>,
|
||||
{ isOver: boolean; canDrop: boolean }
|
||||
>({
|
||||
accept: "document",
|
||||
drop: async (item) => {
|
||||
const document = documents.get(item.id);
|
||||
if (!document) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await document.unpublish({ detach: true });
|
||||
toast.success(
|
||||
t("Unpublished {{ documentName }}", {
|
||||
documentName: document.noun,
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
},
|
||||
canDrop: (item) => {
|
||||
const policy = policies.abilities(item.id);
|
||||
if (!policy) {
|
||||
return true; // optimistic, let the server check for the necessary permission.
|
||||
}
|
||||
|
||||
return policy.unpublish;
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
isOver: monitor.isOver(),
|
||||
canDrop: monitor.canDrop(),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -225,6 +225,32 @@ class WebsocketProvider extends React.Component<Props> {
|
||||
})
|
||||
);
|
||||
|
||||
this.socket.on(
|
||||
"documents.unpublish",
|
||||
action(
|
||||
(event: {
|
||||
document: PartialExcept<Document, "id">;
|
||||
collectionId: string;
|
||||
}) => {
|
||||
const document = event.document;
|
||||
|
||||
// When document is detached as part of unpublishing, only the owner should be able to view it.
|
||||
if (
|
||||
!document.collectionId &&
|
||||
document.createdBy?.id !== currentUserId
|
||||
) {
|
||||
documents.remove(document.id);
|
||||
} else {
|
||||
documents.add(document);
|
||||
}
|
||||
policies.remove(document.id);
|
||||
|
||||
const collection = collections.get(event.collectionId);
|
||||
collection?.removeDocument(document.id);
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
this.socket.on(
|
||||
"documents.archive",
|
||||
action((event: PartialExcept<Document, "id">) => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from "react";
|
||||
import useDictionary from "~/hooks/useDictionary";
|
||||
import getMenuItems from "../menus/block";
|
||||
import { useEditor } from "./EditorContext";
|
||||
import SuggestionsMenu, {
|
||||
Props as SuggestionsMenuProps,
|
||||
} from "./SuggestionsMenu";
|
||||
@@ -11,6 +12,7 @@ type Props = Omit<SuggestionsMenuProps, "renderMenuItem" | "items"> &
|
||||
|
||||
function BlockMenu(props: Props) {
|
||||
const dictionary = useDictionary();
|
||||
const { elementRef } = useEditor();
|
||||
|
||||
return (
|
||||
<SuggestionsMenu
|
||||
@@ -26,7 +28,7 @@ function BlockMenu(props: Props) {
|
||||
shortcut={item.shortcut}
|
||||
/>
|
||||
)}
|
||||
items={getMenuItems(dictionary)}
|
||||
items={getMenuItems(dictionary, elementRef)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,6 +24,54 @@ import useOnClickOutside from "~/hooks/useOnClickOutside";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
import { useEditor } from "./EditorContext";
|
||||
|
||||
type KeyboardShortcutsProps = {
|
||||
popover: ReturnType<typeof usePopoverState>;
|
||||
handleOpen: ({ withReplace }: { withReplace: boolean }) => void;
|
||||
handleCaseSensitive: () => void;
|
||||
handleRegex: () => void;
|
||||
};
|
||||
|
||||
function useKeyboardShortcuts({
|
||||
popover,
|
||||
handleOpen,
|
||||
handleCaseSensitive,
|
||||
handleRegex,
|
||||
}: KeyboardShortcutsProps) {
|
||||
// Open popover
|
||||
useKeyDown(
|
||||
(ev) =>
|
||||
isModKey(ev) &&
|
||||
ev.code === "KeyF" &&
|
||||
// Keyboard handler is through the AppMenu on Desktop v1.2.0+
|
||||
!(Desktop.bridge && "onFindInPage" in Desktop.bridge),
|
||||
(ev) => {
|
||||
ev.preventDefault();
|
||||
handleOpen({ withReplace: ev.altKey });
|
||||
},
|
||||
{ allowInInput: true }
|
||||
);
|
||||
|
||||
// Enable/disable case sensitive search
|
||||
useKeyDown(
|
||||
(ev) => isModKey(ev) && ev.altKey && ev.code === "KeyC" && popover.visible,
|
||||
(ev) => {
|
||||
ev.preventDefault();
|
||||
handleCaseSensitive();
|
||||
},
|
||||
{ allowInInput: true }
|
||||
);
|
||||
|
||||
// Enable/disable regex search
|
||||
useKeyDown(
|
||||
(ev) => isModKey(ev) && ev.altKey && ev.code === "KeyR" && popover.visible,
|
||||
(ev) => {
|
||||
ev.preventDefault();
|
||||
handleRegex();
|
||||
},
|
||||
{ allowInInput: true }
|
||||
);
|
||||
}
|
||||
|
||||
type Props = {
|
||||
/** Whether the find and replace popover is open */
|
||||
open: boolean;
|
||||
@@ -89,42 +137,48 @@ export default function FindAndReplace({
|
||||
}
|
||||
}, [show]);
|
||||
|
||||
useOnClickOutside(popover.unstable_referenceRef, popover.hide);
|
||||
// Callbacks
|
||||
const selectInputText = React.useCallback(() => {
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.setSelectionRange(0, inputRef.current?.value.length);
|
||||
}, []);
|
||||
|
||||
const selectInputReplaceText = React.useCallback(() => {
|
||||
setTimeout(() => {
|
||||
inputReplaceRef.current?.focus();
|
||||
inputReplaceRef.current?.setSelectionRange(
|
||||
0,
|
||||
inputReplaceRef.current?.value.length
|
||||
);
|
||||
}, 100);
|
||||
}, []);
|
||||
|
||||
const handleOpen = React.useCallback(
|
||||
({ withReplace }: { withReplace: boolean }) => {
|
||||
const shouldShowReplace = !readOnly && withReplace;
|
||||
|
||||
// If already open, switch focus to corresponding input text.
|
||||
if (popover.visible) {
|
||||
if (shouldShowReplace) {
|
||||
setShowReplace(true);
|
||||
selectInputReplaceText();
|
||||
} else {
|
||||
selectInputText();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Keyboard shortcuts
|
||||
useKeyDown(
|
||||
(ev) =>
|
||||
isModKey(ev) &&
|
||||
!popover.visible &&
|
||||
ev.code === "KeyF" &&
|
||||
// Keyboard handler is through the AppMenu on Desktop v1.2.0+
|
||||
!(Desktop.bridge && "onFindInPage" in Desktop.bridge),
|
||||
(ev) => {
|
||||
ev.preventDefault();
|
||||
selectionRef.current = window.getSelection()?.toString();
|
||||
popover.show();
|
||||
}
|
||||
);
|
||||
|
||||
useKeyDown(
|
||||
(ev) => isModKey(ev) && ev.altKey && ev.code === "KeyR" && popover.visible,
|
||||
(ev) => {
|
||||
ev.preventDefault();
|
||||
setRegex((state) => !state);
|
||||
if (shouldShowReplace) {
|
||||
setShowReplace(true);
|
||||
}
|
||||
},
|
||||
{ allowInInput: true }
|
||||
[popover, readOnly, selectInputText, selectInputReplaceText]
|
||||
);
|
||||
|
||||
useKeyDown(
|
||||
(ev) => isModKey(ev) && ev.altKey && ev.code === "KeyC" && popover.visible,
|
||||
(ev) => {
|
||||
ev.preventDefault();
|
||||
setCaseSensitive((state) => !state);
|
||||
},
|
||||
{ allowInInput: true }
|
||||
);
|
||||
|
||||
// Callbacks
|
||||
const handleMore = React.useCallback(() => {
|
||||
setShowReplace((state) => !state);
|
||||
setTimeout(() => inputReplaceRef.current?.focus(), 100);
|
||||
@@ -132,68 +186,65 @@ export default function FindAndReplace({
|
||||
|
||||
const handleCaseSensitive = React.useCallback(() => {
|
||||
setCaseSensitive((state) => {
|
||||
const caseSensitive = !state;
|
||||
const isCaseSensitive = !state;
|
||||
|
||||
editor.commands.find({
|
||||
text: searchTerm,
|
||||
caseSensitive,
|
||||
caseSensitive: isCaseSensitive,
|
||||
regexEnabled,
|
||||
});
|
||||
|
||||
return caseSensitive;
|
||||
return isCaseSensitive;
|
||||
});
|
||||
}, [regexEnabled, editor.commands, searchTerm]);
|
||||
|
||||
const handleRegex = React.useCallback(() => {
|
||||
setRegex((state) => {
|
||||
const regexEnabled = !state;
|
||||
const isRegexEnabled = !state;
|
||||
|
||||
editor.commands.find({
|
||||
text: searchTerm,
|
||||
caseSensitive,
|
||||
regexEnabled,
|
||||
regexEnabled: isRegexEnabled,
|
||||
});
|
||||
|
||||
return regexEnabled;
|
||||
return isRegexEnabled;
|
||||
});
|
||||
}, [caseSensitive, editor.commands, searchTerm]);
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(ev: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
function nextPrevious(ev: React.KeyboardEvent<HTMLInputElement>) {
|
||||
function nextPrevious() {
|
||||
if (ev.shiftKey) {
|
||||
editor.commands.prevSearchMatch();
|
||||
} else {
|
||||
editor.commands.nextSearchMatch();
|
||||
}
|
||||
}
|
||||
function selectInputText() {
|
||||
inputRef.current?.setSelectionRange(0, inputRef.current?.value.length);
|
||||
}
|
||||
|
||||
switch (ev.key) {
|
||||
case "Enter": {
|
||||
ev.preventDefault();
|
||||
nextPrevious(ev);
|
||||
nextPrevious();
|
||||
return;
|
||||
}
|
||||
case "g": {
|
||||
if (ev.metaKey) {
|
||||
ev.preventDefault();
|
||||
nextPrevious(ev);
|
||||
nextPrevious();
|
||||
selectInputText();
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "F3": {
|
||||
ev.preventDefault();
|
||||
nextPrevious(ev);
|
||||
nextPrevious();
|
||||
selectInputText();
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
[editor.commands]
|
||||
[editor.commands, selectInputText]
|
||||
);
|
||||
|
||||
const handleReplace = React.useCallback(
|
||||
@@ -243,6 +294,15 @@ export default function FindAndReplace({
|
||||
[handleReplace]
|
||||
);
|
||||
|
||||
useOnClickOutside(popover.unstable_referenceRef, popover.hide);
|
||||
|
||||
useKeyboardShortcuts({
|
||||
popover,
|
||||
handleOpen,
|
||||
handleCaseSensitive,
|
||||
handleRegex,
|
||||
});
|
||||
|
||||
const style: React.CSSProperties = React.useMemo(
|
||||
() => ({
|
||||
position: "fixed",
|
||||
@@ -285,7 +345,7 @@ export default function FindAndReplace({
|
||||
<>
|
||||
<Tooltip
|
||||
content={t("Previous match")}
|
||||
shortcut="shift+enter"
|
||||
shortcut="Shift+Enter"
|
||||
placement="bottom"
|
||||
>
|
||||
<ButtonLarge
|
||||
@@ -295,7 +355,7 @@ export default function FindAndReplace({
|
||||
<CaretUpIcon />
|
||||
</ButtonLarge>
|
||||
</Tooltip>
|
||||
<Tooltip content={t("Next match")} shortcut="enter" placement="bottom">
|
||||
<Tooltip content={t("Next match")} shortcut="Enter" placement="bottom">
|
||||
<ButtonLarge
|
||||
disabled={disabled}
|
||||
onClick={() => editor.commands.nextSearchMatch()}
|
||||
@@ -354,7 +414,11 @@ export default function FindAndReplace({
|
||||
</StyledInput>
|
||||
{navigation}
|
||||
{!readOnly && (
|
||||
<Tooltip content={t("Replace options")} placement="bottom">
|
||||
<Tooltip
|
||||
content={t("Replace options")}
|
||||
shortcut={`${altDisplay}+${metaDisplay}+f`}
|
||||
placement="bottom"
|
||||
>
|
||||
<ButtonLarge onClick={handleMore}>
|
||||
<ReplaceIcon color={theme.textSecondary} />
|
||||
</ButtonLarge>
|
||||
@@ -376,12 +440,28 @@ export default function FindAndReplace({
|
||||
onRequestSubmit={handleReplaceAll}
|
||||
onChange={(ev) => setReplaceTerm(ev.currentTarget.value)}
|
||||
/>
|
||||
<Button onClick={handleReplace} disabled={disabled} neutral>
|
||||
{t("Replace")}
|
||||
</Button>
|
||||
<Button onClick={handleReplaceAll} disabled={disabled} neutral>
|
||||
{t("Replace all")}
|
||||
</Button>
|
||||
<Tooltip
|
||||
content={t("Replace")}
|
||||
shortcut="Enter"
|
||||
placement="bottom"
|
||||
>
|
||||
<Button onClick={handleReplace} disabled={disabled} neutral>
|
||||
{t("Replace")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
content={t("Replace all")}
|
||||
shortcut={`${metaDisplay}+Enter`}
|
||||
placement="bottom"
|
||||
>
|
||||
<Button
|
||||
onClick={handleReplaceAll}
|
||||
disabled={disabled}
|
||||
neutral
|
||||
>
|
||||
{t("Replace all")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
)}
|
||||
</ResizingHeightContainer>
|
||||
|
||||
@@ -6,7 +6,6 @@ import styled, { css } from "styled-components";
|
||||
import { isCode } from "@shared/editor/lib/isCode";
|
||||
import { findParentNode } from "@shared/editor/queries/findParentNode";
|
||||
import { EditorStyleHelper } from "@shared/editor/styles/EditorStyleHelper";
|
||||
import { useComponentSize } from "@shared/hooks/useComponentSize";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import { HEADER_HEIGHT } from "~/components/Header";
|
||||
import { Portal } from "~/components/Portal";
|
||||
@@ -41,7 +40,8 @@ function usePosition({
|
||||
}) {
|
||||
const { view } = useEditor();
|
||||
const { selection } = view.state;
|
||||
const { width: menuWidth, height: menuHeight } = useComponentSize(menuRef);
|
||||
const menuWidth = menuRef.current?.offsetWidth;
|
||||
const menuHeight = menuRef.current?.offsetHeight;
|
||||
|
||||
if (!active || !menuWidth || !menuHeight || !menuRef.current) {
|
||||
return defaultPosition;
|
||||
@@ -78,13 +78,24 @@ function usePosition({
|
||||
|
||||
// position at the top right of code blocks
|
||||
const codeBlock = findParentNode(isCode)(view.state.selection);
|
||||
const noticeBlock = findParentNode(
|
||||
(node) => node.type.name === "container_notice"
|
||||
)(view.state.selection);
|
||||
|
||||
if (codeBlock && view.state.selection.empty) {
|
||||
const element = view.nodeDOM(codeBlock.pos);
|
||||
const bounds = (element as HTMLElement).getBoundingClientRect();
|
||||
selectionBounds.top = bounds.top;
|
||||
selectionBounds.left = bounds.right - menuWidth;
|
||||
selectionBounds.right = bounds.right;
|
||||
if ((codeBlock || noticeBlock) && view.state.selection.empty) {
|
||||
const position = codeBlock
|
||||
? codeBlock.pos
|
||||
: noticeBlock
|
||||
? noticeBlock.pos
|
||||
: null;
|
||||
|
||||
if (position !== null) {
|
||||
const element = view.nodeDOM(position);
|
||||
const bounds = (element as HTMLElement).getBoundingClientRect();
|
||||
selectionBounds.top = bounds.top;
|
||||
selectionBounds.left = bounds.right - menuWidth;
|
||||
selectionBounds.right = bounds.right;
|
||||
}
|
||||
}
|
||||
|
||||
// tables are an oddity, and need their own positioning logic
|
||||
@@ -188,7 +199,8 @@ function usePosition({
|
||||
top: Math.round(top - offsetParent.top),
|
||||
offset: Math.round(offset),
|
||||
maxWidth: Math.min(window.innerWidth, offsetParent.width) - margin * 2,
|
||||
blockSelection: codeBlock || isColSelection || isRowSelection,
|
||||
blockSelection:
|
||||
codeBlock || isColSelection || isRowSelection || noticeBlock,
|
||||
visible: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as React from "react";
|
||||
import filterExcessSeparators from "@shared/editor/lib/filterExcessSeparators";
|
||||
import { getMarkRange } from "@shared/editor/queries/getMarkRange";
|
||||
import { isInCode } from "@shared/editor/queries/isInCode";
|
||||
import { isInNotice } from "@shared/editor/queries/isInNotice";
|
||||
import { isMarkActive } from "@shared/editor/queries/isMarkActive";
|
||||
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
|
||||
import { getColumnIndex, getRowIndex } from "@shared/editor/queries/table";
|
||||
@@ -18,6 +19,7 @@ import getCodeMenuItems from "../menus/code";
|
||||
import getDividerMenuItems from "../menus/divider";
|
||||
import getFormattingMenuItems from "../menus/formatting";
|
||||
import getImageMenuItems from "../menus/image";
|
||||
import getNoticeMenuItems from "../menus/notice";
|
||||
import getReadOnlyMenuItems from "../menus/readOnly";
|
||||
import getTableMenuItems from "../menus/table";
|
||||
import getTableColMenuItems from "../menus/tableCol";
|
||||
@@ -55,6 +57,10 @@ function useIsActive(state: EditorState) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isInNotice(state) && selection.from > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!selection || selection.empty) {
|
||||
return false;
|
||||
}
|
||||
@@ -184,6 +190,7 @@ export default function SelectionToolbar(props: Props) {
|
||||
selection instanceof NodeSelection &&
|
||||
selection.node.type.name === "attachment";
|
||||
const isCodeSelection = isInCode(state, { onlyBlock: true });
|
||||
const isNoticeSelection = isInNotice(state);
|
||||
|
||||
let items: MenuItem[] = [];
|
||||
|
||||
@@ -203,6 +210,8 @@ export default function SelectionToolbar(props: Props) {
|
||||
items = getDividerMenuItems(state, dictionary);
|
||||
} else if (readOnly) {
|
||||
items = getReadOnlyMenuItems(state, !!canUpdate, dictionary);
|
||||
} else if (isNoticeSelection && selection.empty) {
|
||||
items = getNoticeMenuItems(state, readOnly, dictionary);
|
||||
} else {
|
||||
items = getFormattingMenuItems(state, isTemplate, isMobile, dictionary);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Plugin, PluginKey } from "prosemirror-state";
|
||||
import Extension from "@shared/editor/lib/Extension";
|
||||
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
|
||||
/**
|
||||
* A plugin that allows overriding the default behavior of the editor to allow
|
||||
@@ -11,16 +12,34 @@ export default class ClipboardTextSerializer extends Extension {
|
||||
}
|
||||
|
||||
get plugins() {
|
||||
const serializer = this.editor.extensions.serializer();
|
||||
const mdSerializer = this.editor.extensions.serializer();
|
||||
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey("clipboardTextSerializer"),
|
||||
props: {
|
||||
clipboardTextSerializer: (slice) =>
|
||||
serializer.serialize(slice.content, {
|
||||
softBreak: true,
|
||||
}),
|
||||
clipboardTextSerializer: (slice) => {
|
||||
const isMultiline = slice.content.childCount > 1;
|
||||
|
||||
// This is a cheap way to determine if the content is "complex",
|
||||
// aka it has multiple marks or formatting. In which case we'll use
|
||||
// markdown formatting
|
||||
const copyAsMarkdown =
|
||||
isMultiline ||
|
||||
slice.content.content.some(
|
||||
(node) => node.content.content.length > 1
|
||||
);
|
||||
|
||||
return copyAsMarkdown
|
||||
? mdSerializer.serialize(slice.content, {
|
||||
softBreak: true,
|
||||
})
|
||||
: slice.content.content
|
||||
.map((node) =>
|
||||
ProsemirrorHelper.toPlainText(node, this.editor.schema)
|
||||
)
|
||||
.join("");
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -25,52 +25,6 @@ import { isDocumentUrl, isUrl } from "@shared/utils/urls";
|
||||
import stores from "~/stores";
|
||||
import PasteMenu from "../components/PasteMenu";
|
||||
|
||||
/**
|
||||
* Checks if the HTML string is likely coming from Dropbox Paper.
|
||||
*
|
||||
* @param html The HTML string to check.
|
||||
* @returns True if the HTML string is likely coming from Dropbox Paper.
|
||||
*/
|
||||
function isDropboxPaper(html: string): boolean {
|
||||
return html?.includes("usually-unique-id");
|
||||
}
|
||||
|
||||
function sliceSingleNode(slice: Slice) {
|
||||
return slice.openStart === 0 &&
|
||||
slice.openEnd === 0 &&
|
||||
slice.content.childCount === 1
|
||||
? slice.content.firstChild
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the text contents of an HTML string and returns the src of the first
|
||||
* iframe if it exists.
|
||||
*
|
||||
* @param text The HTML string to parse.
|
||||
* @returns The src of the first iframe if it exists, or undefined.
|
||||
*/
|
||||
function parseSingleIframeSrc(html: string) {
|
||||
try {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, "text/html");
|
||||
|
||||
if (
|
||||
doc.body.children.length === 1 &&
|
||||
doc.body.firstElementChild?.tagName === "IFRAME"
|
||||
) {
|
||||
const iframe = doc.body.firstElementChild;
|
||||
const src = iframe.getAttribute("src");
|
||||
if (src) {
|
||||
return src;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore the million ways parsing could fail.
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export default class PasteHandler extends Extension {
|
||||
state: {
|
||||
open: boolean;
|
||||
@@ -261,9 +215,12 @@ export default class PasteHandler extends Extension {
|
||||
// If the text on the clipboard looks like Markdown OR there is no
|
||||
// html on the clipboard then try to parse content as Markdown
|
||||
if (
|
||||
(isMarkdown(text) && !isDropboxPaper(html)) ||
|
||||
(isMarkdown(text) &&
|
||||
!isDropboxPaper(html) &&
|
||||
!isContainingImage(html)) ||
|
||||
pasteCodeLanguage === "markdown" ||
|
||||
this.shiftKey
|
||||
this.shiftKey ||
|
||||
!html
|
||||
) {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -475,3 +432,59 @@ export default class PasteHandler extends Extension {
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the HTML string is likely coming from Dropbox Paper.
|
||||
*
|
||||
* @param html The HTML string to check.
|
||||
* @returns True if the HTML string is likely coming from Dropbox Paper.
|
||||
*/
|
||||
function isDropboxPaper(html: string): boolean {
|
||||
return html?.includes("usually-unique-id");
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the HTML string contains an image.
|
||||
*
|
||||
* @param html The HTML string to check.
|
||||
* @returns True if the HTML string contains an image.
|
||||
*/
|
||||
function isContainingImage(html: string): boolean {
|
||||
return html?.includes("<img");
|
||||
}
|
||||
|
||||
function sliceSingleNode(slice: Slice) {
|
||||
return slice.openStart === 0 &&
|
||||
slice.openEnd === 0 &&
|
||||
slice.content.childCount === 1
|
||||
? slice.content.firstChild
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the text contents of an HTML string and returns the src of the first
|
||||
* iframe if it exists.
|
||||
*
|
||||
* @param text The HTML string to parse.
|
||||
* @returns The src of the first iframe if it exists, or undefined.
|
||||
*/
|
||||
function parseSingleIframeSrc(html: string) {
|
||||
try {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, "text/html");
|
||||
|
||||
if (
|
||||
doc.body.children.length === 1 &&
|
||||
doc.body.firstElementChild?.tagName === "IFRAME"
|
||||
) {
|
||||
const iframe = doc.body.firstElementChild;
|
||||
const src = iframe.getAttribute("src");
|
||||
if (src) {
|
||||
return src;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore the million ways parsing could fail.
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@ import { InputRule } from "@shared/editor/lib/InputRule";
|
||||
|
||||
const rightArrow = new InputRule(/->$/, "→");
|
||||
const emdash = new InputRule(/--$/, "—");
|
||||
const oneHalf = new InputRule(/(?:^|\s)1\/2$/, "½");
|
||||
const threeQuarters = new InputRule(/(?:^|\s)3\/4$/, "¾");
|
||||
const oneHalf = new InputRule(/(?:^|\s)(1\/2)$/, "½");
|
||||
const threeQuarters = new InputRule(/(?:^|\s)(3\/4)$/, "¾");
|
||||
const copyright = new InputRule(/\(c\)$/, "©️");
|
||||
const registered = new InputRule(/\(r\)$/, "®️");
|
||||
const trademarked = new InputRule(/\(tm\)$/, "™️");
|
||||
|
||||
@@ -19,7 +19,9 @@ export default class Suggestion extends Extension {
|
||||
super(options);
|
||||
|
||||
this.openRegex = new RegExp(
|
||||
`(?:^|\\s|\\()${escapeRegExp(this.options.trigger)}(${`[\\p{L}\\p{M}\\d${
|
||||
`(?:^|\\s|\\()${escapeRegExp(
|
||||
this.options.trigger
|
||||
)}(${`[\\p{L}\/\\p{M}\\d${
|
||||
this.options.allowSpaces ? "\\s{1}" : ""
|
||||
}\\.]+`})${this.options.requireSearchTerm ? "" : "?"}$`,
|
||||
"u"
|
||||
|
||||
@@ -13,13 +13,11 @@ export default function attachmentMenuItems(
|
||||
name: "replaceAttachment",
|
||||
tooltip: dictionary.replaceAttachment,
|
||||
icon: <ReplaceIcon />,
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
name: "deleteAttachment",
|
||||
tooltip: dictionary.deleteAttachment,
|
||||
icon: <TrashIcon />,
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
|
||||
@@ -38,7 +38,12 @@ const Img = styled(Image)`
|
||||
height: 18px;
|
||||
`;
|
||||
|
||||
export default function blockMenuItems(dictionary: Dictionary): MenuItem[] {
|
||||
export default function blockMenuItems(
|
||||
dictionary: Dictionary,
|
||||
documentRef: React.RefObject<HTMLDivElement>
|
||||
): MenuItem[] {
|
||||
const documentWidth = documentRef.current?.clientWidth ?? 0;
|
||||
|
||||
return [
|
||||
{
|
||||
name: "heading",
|
||||
@@ -119,7 +124,11 @@ export default function blockMenuItems(dictionary: Dictionary): MenuItem[] {
|
||||
name: "table",
|
||||
title: dictionary.table,
|
||||
icon: <TableIcon />,
|
||||
attrs: { rowsCount: 3, colsCount: 3 },
|
||||
attrs: {
|
||||
rowsCount: 3,
|
||||
colsCount: 3,
|
||||
colWidth: documentWidth / 3,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "blockquote",
|
||||
|
||||
@@ -33,14 +33,12 @@ export default function imageMenuItems(
|
||||
name: "alignLeft",
|
||||
tooltip: dictionary.alignLeft,
|
||||
icon: <AlignImageLeftIcon />,
|
||||
visible: true,
|
||||
active: isLeftAligned,
|
||||
},
|
||||
{
|
||||
name: "alignCenter",
|
||||
tooltip: dictionary.alignCenter,
|
||||
icon: <AlignImageCenterIcon />,
|
||||
visible: true,
|
||||
active: (state) =>
|
||||
isNodeActive(schema.nodes.image)(state) &&
|
||||
!isLeftAligned(state) &&
|
||||
@@ -51,19 +49,16 @@ export default function imageMenuItems(
|
||||
name: "alignRight",
|
||||
tooltip: dictionary.alignRight,
|
||||
icon: <AlignImageRightIcon />,
|
||||
visible: true,
|
||||
active: isRightAligned,
|
||||
},
|
||||
{
|
||||
name: "alignFullWidth",
|
||||
tooltip: dictionary.alignFullWidth,
|
||||
icon: <AlignFullWidthIcon />,
|
||||
visible: true,
|
||||
active: isFullWidthAligned,
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
name: "downloadImage",
|
||||
@@ -75,13 +70,11 @@ export default function imageMenuItems(
|
||||
name: "replaceImage",
|
||||
tooltip: dictionary.replaceImage,
|
||||
icon: <ReplaceIcon />,
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
name: "deleteImage",
|
||||
tooltip: dictionary.deleteImage,
|
||||
icon: <TrashIcon />,
|
||||
visible: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import {
|
||||
DoneIcon,
|
||||
ExpandedIcon,
|
||||
InfoIcon,
|
||||
StarredIcon,
|
||||
WarningIcon,
|
||||
} from "outline-icons";
|
||||
import { EditorState } from "prosemirror-state";
|
||||
import * as React from "react";
|
||||
import { NoticeTypes } from "@shared/editor/nodes/Notice";
|
||||
import { MenuItem } from "@shared/editor/types";
|
||||
import { Dictionary } from "~/hooks/useDictionary";
|
||||
|
||||
export default function noticeMenuItems(
|
||||
state: EditorState,
|
||||
readOnly: boolean | undefined,
|
||||
dictionary: Dictionary
|
||||
): MenuItem[] {
|
||||
const node = state.selection.$from.node(-1);
|
||||
const currentStyle = node?.attrs.style as NoticeTypes;
|
||||
|
||||
const mapping = {
|
||||
[NoticeTypes.Info]: dictionary.infoNotice,
|
||||
[NoticeTypes.Warning]: dictionary.warningNotice,
|
||||
[NoticeTypes.Success]: dictionary.successNotice,
|
||||
[NoticeTypes.Tip]: dictionary.tipNotice,
|
||||
};
|
||||
|
||||
return [
|
||||
{
|
||||
name: "container_notice",
|
||||
visible: !readOnly,
|
||||
label: mapping[currentStyle],
|
||||
icon: <ExpandedIcon />,
|
||||
children: [
|
||||
{
|
||||
name: NoticeTypes.Info,
|
||||
icon: <InfoIcon />,
|
||||
label: dictionary.infoNotice,
|
||||
active: () => currentStyle === NoticeTypes.Info,
|
||||
},
|
||||
{
|
||||
name: NoticeTypes.Success,
|
||||
icon: <DoneIcon />,
|
||||
label: dictionary.successNotice,
|
||||
active: () => currentStyle === NoticeTypes.Success,
|
||||
},
|
||||
{
|
||||
name: NoticeTypes.Warning,
|
||||
icon: <WarningIcon />,
|
||||
label: dictionary.warningNotice,
|
||||
active: () => currentStyle === NoticeTypes.Warning,
|
||||
},
|
||||
{
|
||||
name: NoticeTypes.Tip,
|
||||
icon: <StarredIcon />,
|
||||
label: dictionary.tipNotice,
|
||||
active: () => currentStyle === NoticeTypes.Tip,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -2,13 +2,14 @@ import * as React from "react";
|
||||
import usePersistedState from "~/hooks/usePersistedState";
|
||||
import useStores from "./useStores";
|
||||
|
||||
export function usePinnedDocuments(
|
||||
urlId: "home" | string,
|
||||
collectionId?: string
|
||||
) {
|
||||
type UrlId = "home" | string;
|
||||
|
||||
export const pinsCacheKey = (urlId: UrlId) => `pins-${urlId}`;
|
||||
|
||||
export function usePinnedDocuments(urlId: UrlId, collectionId?: string) {
|
||||
const { pins } = useStores();
|
||||
const [pinsCacheCount, setPinsCacheCount] = usePersistedState<number>(
|
||||
`pins-${urlId}`,
|
||||
pinsCacheKey(urlId),
|
||||
0
|
||||
);
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ type RequestResponse<T> = {
|
||||
error: unknown;
|
||||
/** Whether the request is currently in progress. */
|
||||
loading: boolean;
|
||||
/** Whether the request has completed - useful to check if the request has completed at least once. */
|
||||
loaded: boolean;
|
||||
/** Function to start the request. */
|
||||
request: () => Promise<T | undefined>;
|
||||
};
|
||||
@@ -26,6 +28,7 @@ export default function useRequest<T = unknown>(
|
||||
const isMounted = useIsMounted();
|
||||
const [data, setData] = React.useState<T>();
|
||||
const [loading, setLoading] = React.useState<boolean>(false);
|
||||
const [loaded, setLoaded] = React.useState<boolean>(false);
|
||||
const [error, setError] = React.useState();
|
||||
|
||||
const request = React.useCallback(async () => {
|
||||
@@ -36,6 +39,7 @@ export default function useRequest<T = unknown>(
|
||||
if (isMounted()) {
|
||||
setData(response);
|
||||
setError(undefined);
|
||||
setLoaded(true);
|
||||
}
|
||||
return response;
|
||||
} catch (err) {
|
||||
@@ -57,5 +61,5 @@ export default function useRequest<T = unknown>(
|
||||
}
|
||||
}, [request, makeRequestOnMount]);
|
||||
|
||||
return { data, loading, error, request };
|
||||
return { data, loading, loaded, error, request };
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useHistory } from "react-router-dom";
|
||||
import { useMenuState, MenuButton, MenuButtonHTMLProps } from "reakit/Menu";
|
||||
import { VisuallyHidden } from "reakit/VisuallyHidden";
|
||||
import { toast } from "sonner";
|
||||
import { SubscriptionType } from "@shared/types";
|
||||
import { getEventFiles } from "@shared/utils/files";
|
||||
import Collection from "~/models/Collection";
|
||||
import ContextMenu, { Placement } from "~/components/ContextMenu";
|
||||
@@ -31,10 +32,13 @@ import {
|
||||
createTemplate,
|
||||
archiveCollection,
|
||||
restoreCollection,
|
||||
subscribeCollection,
|
||||
unsubscribeCollection,
|
||||
} from "~/actions/definitions/collections";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useRequest from "~/hooks/useRequest";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { MenuItem } from "~/types";
|
||||
import { newDocumentPath } from "~/utils/routeHelpers";
|
||||
@@ -63,11 +67,28 @@ function CollectionMenu({
|
||||
placement,
|
||||
});
|
||||
const team = useCurrentTeam();
|
||||
const { documents, dialogs } = useStores();
|
||||
const { documents, dialogs, subscriptions } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const history = useHistory();
|
||||
const file = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const {
|
||||
loading: subscriptionLoading,
|
||||
loaded: subscriptionLoaded,
|
||||
request: loadSubscription,
|
||||
} = useRequest(() =>
|
||||
subscriptions.fetchOne({
|
||||
collectionId: collection.id,
|
||||
event: SubscriptionType.Document,
|
||||
})
|
||||
);
|
||||
|
||||
const handlePointerEnter = React.useCallback(() => {
|
||||
if (!subscriptionLoading && !subscriptionLoaded) {
|
||||
void loadSubscription();
|
||||
}
|
||||
}, [subscriptionLoading, subscriptionLoaded, loadSubscription]);
|
||||
|
||||
const handleExport = React.useCallback(() => {
|
||||
dialogs.openModal({
|
||||
title: t("Export collection"),
|
||||
@@ -157,6 +178,8 @@ function CollectionMenu({
|
||||
actionToMenuItem(restoreCollection, context),
|
||||
actionToMenuItem(starCollection, context),
|
||||
actionToMenuItem(unstarCollection, context),
|
||||
actionToMenuItem(subscribeCollection, context),
|
||||
actionToMenuItem(unsubscribeCollection, context),
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
@@ -272,9 +295,15 @@ function CollectionMenu({
|
||||
</label>
|
||||
</VisuallyHidden>
|
||||
{label ? (
|
||||
<MenuButton {...menu}>{label}</MenuButton>
|
||||
<MenuButton {...menu} onPointerEnter={handlePointerEnter}>
|
||||
{label}
|
||||
</MenuButton>
|
||||
) : (
|
||||
<OverflowMenuButton aria-label={t("Show menu")} {...menu} />
|
||||
<OverflowMenuButton
|
||||
aria-label={t("Show menu")}
|
||||
{...menu}
|
||||
onPointerEnter={handlePointerEnter}
|
||||
/>
|
||||
)}
|
||||
<ContextMenu
|
||||
{...menu}
|
||||
|
||||
+42
-14
@@ -1,6 +1,6 @@
|
||||
import capitalize from "lodash/capitalize";
|
||||
import isEmpty from "lodash/isEmpty";
|
||||
import isUndefined from "lodash/isUndefined";
|
||||
import noop from "lodash/noop";
|
||||
import { observer } from "mobx-react";
|
||||
import { EditIcon, InputIcon, RestoreIcon, SearchIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
@@ -12,7 +12,7 @@ import { toast } from "sonner";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { s } from "@shared/styles";
|
||||
import { UserPreference } from "@shared/types";
|
||||
import { SubscriptionType, UserPreference } from "@shared/types";
|
||||
import { getEventFiles } from "@shared/utils/files";
|
||||
import Document from "~/models/Document";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
@@ -57,7 +57,7 @@ import useMobile from "~/hooks/useMobile";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useRequest from "~/hooks/useRequest";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { MenuItem } from "~/types";
|
||||
import { MenuItem, MenuItemButton } from "~/types";
|
||||
import { documentEditPath } from "~/utils/routeHelpers";
|
||||
import { MenuContext, useMenuContext } from "./MenuContext";
|
||||
|
||||
@@ -92,22 +92,38 @@ type MenuTriggerProps = {
|
||||
const MenuTrigger: React.FC<MenuTriggerProps> = ({ label, onTrigger }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { subscriptions } = useStores();
|
||||
const { subscriptions, pins } = useStores();
|
||||
const { model: document, menuState } = useMenuContext<Document>();
|
||||
|
||||
const { data, loading, error, request } = useRequest(() =>
|
||||
subscriptions.fetchPage({
|
||||
documentId: document.id,
|
||||
event: "documents.update",
|
||||
})
|
||||
const {
|
||||
loading: auxDataLoading,
|
||||
loaded: auxDataLoaded,
|
||||
request: auxDataRequest,
|
||||
} = useRequest(() =>
|
||||
Promise.all([
|
||||
subscriptions.fetchOne({
|
||||
documentId: document.id,
|
||||
event: SubscriptionType.Document,
|
||||
}),
|
||||
document.collectionId
|
||||
? subscriptions.fetchOne({
|
||||
collectionId: document.collectionId,
|
||||
event: SubscriptionType.Document,
|
||||
})
|
||||
: noop,
|
||||
pins.fetchOne({
|
||||
documentId: document.id,
|
||||
collectionId: document.collectionId ?? null,
|
||||
}),
|
||||
])
|
||||
);
|
||||
|
||||
const handlePointerEnter = React.useCallback(() => {
|
||||
if (isUndefined(data ?? error) && !loading) {
|
||||
void request();
|
||||
if (!auxDataLoading && !auxDataLoaded) {
|
||||
void auxDataRequest();
|
||||
void document.loadRelations();
|
||||
}
|
||||
}, [data, error, loading, request, document]);
|
||||
}, [auxDataLoading, auxDataLoaded, auxDataRequest, document]);
|
||||
|
||||
return label ? (
|
||||
<MenuButton
|
||||
@@ -245,8 +261,20 @@ const MenuContent: React.FC<MenuContentProps> = observer(function MenuContent_({
|
||||
},
|
||||
actionToMenuItem(starDocument, context),
|
||||
actionToMenuItem(unstarDocument, context),
|
||||
actionToMenuItem(subscribeDocument, context),
|
||||
actionToMenuItem(unsubscribeDocument, context),
|
||||
{
|
||||
...actionToMenuItem(subscribeDocument, context),
|
||||
disabled: collection?.isSubscribed,
|
||||
tooltip: collection?.isSubscribed
|
||||
? t("Subscription inherited from collection")
|
||||
: undefined,
|
||||
} as MenuItemButton,
|
||||
{
|
||||
...actionToMenuItem(unsubscribeDocument, context),
|
||||
disabled: collection?.isSubscribed,
|
||||
tooltip: collection?.isSubscribed
|
||||
? t("Subscription inherited from collection")
|
||||
: undefined,
|
||||
} as MenuItemButton,
|
||||
{
|
||||
type: "button",
|
||||
title: `${t("Find and replace")}…`,
|
||||
|
||||
@@ -24,7 +24,6 @@ const NotificationMenu: React.FC = () => {
|
||||
{
|
||||
type: "button",
|
||||
title: t("Notification settings"),
|
||||
visible: true,
|
||||
onClick: () => performAction(navigateToNotificationSettings, context),
|
||||
},
|
||||
],
|
||||
|
||||
@@ -3,6 +3,7 @@ import { TableOfContentsIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MenuButton, useMenuState } from "reakit/Menu";
|
||||
import styled from "styled-components";
|
||||
import Button from "~/components/Button";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
import Template from "~/components/ContextMenu/Template";
|
||||
@@ -23,28 +24,29 @@ function TableOfContentsMenu() {
|
||||
Infinity
|
||||
);
|
||||
|
||||
// @ts-expect-error check
|
||||
const items: MenuItem[] = React.useMemo(() => {
|
||||
const i = [
|
||||
{
|
||||
type: "heading",
|
||||
visible: true,
|
||||
title: t("Contents"),
|
||||
},
|
||||
...headings.map((heading) => ({
|
||||
type: "link",
|
||||
href: `#${heading.id}`,
|
||||
title: t(heading.title),
|
||||
title: <HeadingWrapper>{t(heading.title)}</HeadingWrapper>,
|
||||
level: heading.level - minHeading,
|
||||
})),
|
||||
];
|
||||
] as MenuItem[];
|
||||
|
||||
if (i.length === 1) {
|
||||
i.push({
|
||||
type: "link",
|
||||
href: "#",
|
||||
title: t("Headings you add to the document will appear here"),
|
||||
// @ts-expect-error check
|
||||
title: (
|
||||
<HeadingWrapper>
|
||||
{t("Headings you add to the document will appear here")}
|
||||
</HeadingWrapper>
|
||||
),
|
||||
disabled: true,
|
||||
});
|
||||
}
|
||||
@@ -71,4 +73,10 @@ function TableOfContentsMenu() {
|
||||
);
|
||||
}
|
||||
|
||||
const HeadingWrapper = styled.div`
|
||||
max-width: 100%;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
`;
|
||||
|
||||
export default observer(TableOfContentsMenu);
|
||||
|
||||
@@ -5,8 +5,6 @@ import Field from "./decorators/Field";
|
||||
class AuthenticationProvider extends Model {
|
||||
static modelName = "AuthenticationProvider";
|
||||
|
||||
id: string;
|
||||
|
||||
displayName: string;
|
||||
|
||||
name: string;
|
||||
|
||||
@@ -129,6 +129,16 @@ export default class Collection extends ParanoidModel {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether there is a subscription for this collection in the store.
|
||||
*
|
||||
* @returns True if there is a subscription, false otherwise.
|
||||
*/
|
||||
@computed
|
||||
get isSubscribed(): boolean {
|
||||
return !!this.store.rootStore.subscriptions.getByCollectionId(this.id);
|
||||
}
|
||||
|
||||
@computed
|
||||
get isManualSort(): boolean {
|
||||
return this.sort.field === "index";
|
||||
@@ -376,6 +386,22 @@ export default class Collection extends ParanoidModel {
|
||||
@action
|
||||
unstar = async () => this.store.unstar(this);
|
||||
|
||||
/**
|
||||
* Subscribes the current user to this collection.
|
||||
*
|
||||
* @returns A promise that resolves when the subscription is created.
|
||||
*/
|
||||
@action
|
||||
subscribe = () => this.store.subscribe(this);
|
||||
|
||||
/**
|
||||
* Unsubscribes the current user from this collection.
|
||||
*
|
||||
* @returns A promise that resolves when the subscription is destroyed.
|
||||
*/
|
||||
@action
|
||||
unsubscribe = () => this.store.unsubscribe(this);
|
||||
|
||||
archive = () => this.store.archive(this);
|
||||
|
||||
restore = () => this.store.restore(this);
|
||||
|
||||
+15
-7
@@ -27,6 +27,7 @@ import { client } from "~/utils/ApiClient";
|
||||
import { settingsPath } from "~/utils/routeHelpers";
|
||||
import Collection from "./Collection";
|
||||
import Notification from "./Notification";
|
||||
import Pin from "./Pin";
|
||||
import View from "./View";
|
||||
import ArchivableModel from "./base/ArchivableModel";
|
||||
import Field from "./decorators/Field";
|
||||
@@ -307,9 +308,7 @@ export default class Document extends ArchivableModel implements Searchable {
|
||||
*/
|
||||
@computed
|
||||
get isSubscribed(): boolean {
|
||||
return !!this.store.rootStore.subscriptions.orderedData.find(
|
||||
(subscription) => subscription.documentId === this.id
|
||||
);
|
||||
return !!this.store.rootStore.subscriptions.getByDocumentId(this.id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -450,7 +449,11 @@ export default class Document extends ArchivableModel implements Searchable {
|
||||
restore = (options?: { revisionId?: string; collectionId?: string }) =>
|
||||
this.store.restore(this, options);
|
||||
|
||||
unpublish = () => this.store.unpublish(this);
|
||||
unpublish = (
|
||||
options: { detach?: boolean } = {
|
||||
detach: false,
|
||||
}
|
||||
) => this.store.unpublish(this, options);
|
||||
|
||||
@action
|
||||
enableEmbeds = () => {
|
||||
@@ -463,12 +466,17 @@ export default class Document extends ArchivableModel implements Searchable {
|
||||
};
|
||||
|
||||
@action
|
||||
pin = (collectionId?: string | null) =>
|
||||
this.store.rootStore.pins.create({
|
||||
pin = async (collectionId?: string | null) => {
|
||||
const pin = new Pin({}, this.store.rootStore.pins);
|
||||
|
||||
await pin.save({
|
||||
documentId: this.id,
|
||||
...(collectionId ? { collectionId } : {}),
|
||||
});
|
||||
|
||||
return pin;
|
||||
};
|
||||
|
||||
@action
|
||||
unpin = (collectionId?: string) => {
|
||||
const pin = this.store.rootStore.pins.orderedData.find(
|
||||
@@ -501,7 +509,7 @@ export default class Document extends ArchivableModel implements Searchable {
|
||||
* @returns A promise that resolves when the subscription is destroyed.
|
||||
*/
|
||||
@action
|
||||
unsubscribe = (userId: string) => this.store.unsubscribe(userId, this);
|
||||
unsubscribe = () => this.store.unsubscribe(this);
|
||||
|
||||
@action
|
||||
view = () => {
|
||||
|
||||
@@ -7,8 +7,6 @@ import Relation from "./decorators/Relation";
|
||||
class Event<T extends Model> extends Model {
|
||||
static modelName = "Event";
|
||||
|
||||
id: string;
|
||||
|
||||
name: string;
|
||||
|
||||
modelId: string | undefined;
|
||||
|
||||
@@ -11,8 +11,6 @@ import Model from "./base/Model";
|
||||
class FileOperation extends Model {
|
||||
static modelName = "FileOperation";
|
||||
|
||||
id: string;
|
||||
|
||||
@observable
|
||||
state: FileOperationState;
|
||||
|
||||
|
||||
@@ -12,8 +12,6 @@ import Relation from "~/models/decorators/Relation";
|
||||
class Integration<T = unknown> extends Model {
|
||||
static modelName = "Integration";
|
||||
|
||||
id: string;
|
||||
|
||||
type: IntegrationType;
|
||||
|
||||
service: IntegrationService;
|
||||
|
||||
@@ -9,8 +9,6 @@ import Relation from "./decorators/Relation";
|
||||
class Membership extends Model {
|
||||
static modelName = "Membership";
|
||||
|
||||
id: string;
|
||||
|
||||
userId: string;
|
||||
|
||||
@Relation(() => User, { onDelete: "cascade" })
|
||||
|
||||
+31
-1
@@ -1,15 +1,21 @@
|
||||
import { observable } from "mobx";
|
||||
import PinsStore from "~/stores/PinsStore";
|
||||
import { setPersistedState } from "~/hooks/usePersistedState";
|
||||
import { pinsCacheKey } from "~/hooks/usePinnedDocuments";
|
||||
import Collection from "./Collection";
|
||||
import Document from "./Document";
|
||||
import Model from "./base/Model";
|
||||
import Field from "./decorators/Field";
|
||||
import { AfterCreate, AfterDelete, AfterRemove } from "./decorators/Lifecycle";
|
||||
import Relation from "./decorators/Relation";
|
||||
|
||||
class Pin extends Model {
|
||||
static modelName = "Pin";
|
||||
|
||||
store: PinsStore;
|
||||
|
||||
/** The collection ID that the document is pinned to. If empty the document is pinned to home. */
|
||||
collectionId: string;
|
||||
collectionId: string | null;
|
||||
|
||||
/** The collection that the document is pinned to. If empty the document is pinned to home. */
|
||||
@Relation(() => Collection, { onDelete: "cascade" })
|
||||
@@ -26,6 +32,30 @@ class Pin extends Model {
|
||||
@observable
|
||||
@Field
|
||||
index: string;
|
||||
|
||||
@AfterCreate
|
||||
@AfterDelete
|
||||
@AfterRemove
|
||||
static updateCache(model: Pin) {
|
||||
const pins = model.store;
|
||||
|
||||
// Pinned to home
|
||||
if (!model.collectionId) {
|
||||
setPersistedState(pinsCacheKey("home"), pins.home.length);
|
||||
return;
|
||||
}
|
||||
|
||||
// Pinned to collection
|
||||
const collection = pins.rootStore.collections.get(model.collectionId);
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPersistedState(
|
||||
pinsCacheKey(collection.urlId),
|
||||
pins.inCollection(collection.id).length
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Pin;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { observable } from "mobx";
|
||||
import Collection from "./Collection";
|
||||
import Document from "./Document";
|
||||
import User from "./User";
|
||||
import Model from "./base/Model";
|
||||
@@ -25,6 +26,13 @@ class Subscription extends Model {
|
||||
@Relation(() => Document, { onDelete: "cascade" })
|
||||
document?: Document;
|
||||
|
||||
/** The collection ID being subscribed to */
|
||||
collectionId: string;
|
||||
|
||||
/** The collection being subscribed to */
|
||||
@Relation(() => Collection, { onDelete: "cascade" })
|
||||
collection?: Collection;
|
||||
|
||||
/** The event being subscribed to */
|
||||
@Field
|
||||
@observable
|
||||
|
||||
@@ -7,8 +7,6 @@ import Relation from "./decorators/Relation";
|
||||
class View extends Model {
|
||||
static modelName = "View";
|
||||
|
||||
id: string;
|
||||
|
||||
documentId: string;
|
||||
|
||||
@Relation(() => Document)
|
||||
|
||||
@@ -73,10 +73,13 @@ const CollectionScene = observer(function _CollectionScene() {
|
||||
const sidebarContext = useLocationSidebarContext();
|
||||
|
||||
const id = params.id || "";
|
||||
const urlId = id.split("-").pop() ?? "";
|
||||
|
||||
const collection: Collection | null | undefined =
|
||||
collections.getByUrl(id) || collections.get(id);
|
||||
const can = usePolicy(collection);
|
||||
const { pins, count } = usePinnedDocuments(id, collection?.id);
|
||||
|
||||
const { pins, count } = usePinnedDocuments(urlId, collection?.id);
|
||||
const [collectionTab, setCollectionTab] = usePersistedState<CollectionPath>(
|
||||
`collection-tab:${collection?.id}`,
|
||||
collection?.hasDescription
|
||||
|
||||
@@ -183,7 +183,7 @@ function DataLoader({ match, children }: Props) {
|
||||
|
||||
// Prevents unauthorized request to load share information for the document
|
||||
// when viewing a public share link
|
||||
if (can.read && !document.isDeleted) {
|
||||
if (can.read && !document.isDeleted && !revisionId) {
|
||||
if (team.getPreference(TeamPreference.Commenting)) {
|
||||
void comments.fetchAll({
|
||||
documentId: document.id,
|
||||
@@ -199,7 +199,17 @@ function DataLoader({ match, children }: Props) {
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [can.read, can.update, document, isEditRoute, comments, team, shares, ui]);
|
||||
}, [
|
||||
can.read,
|
||||
can.update,
|
||||
document,
|
||||
isEditRoute,
|
||||
comments,
|
||||
team,
|
||||
shares,
|
||||
ui,
|
||||
revisionId,
|
||||
]);
|
||||
|
||||
if (error) {
|
||||
return error instanceof OfflineError ? (
|
||||
|
||||
@@ -542,14 +542,6 @@ class DocumentScene extends React.Component<Props> {
|
||||
</RevisionContainer>
|
||||
) : (
|
||||
<>
|
||||
{showContents && (
|
||||
<ContentsContainer
|
||||
docFullWidth={document.fullWidth}
|
||||
position={tocPos}
|
||||
>
|
||||
<Contents />
|
||||
</ContentsContainer>
|
||||
)}
|
||||
<MeasuredContainer
|
||||
name="document"
|
||||
as={EditorContainer}
|
||||
@@ -600,6 +592,14 @@ class DocumentScene extends React.Component<Props> {
|
||||
) : null}
|
||||
</Editor>
|
||||
</MeasuredContainer>
|
||||
{showContents && (
|
||||
<ContentsContainer
|
||||
docFullWidth={document.fullWidth}
|
||||
position={tocPos}
|
||||
>
|
||||
<Contents />
|
||||
</ContentsContainer>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</React.Suspense>
|
||||
|
||||
@@ -281,15 +281,18 @@ function DocumentHeader({
|
||||
limit={isCompact ? 3 : undefined}
|
||||
/>
|
||||
)}
|
||||
{(isEditing || !user?.separateEditMode) && !isTemplate && isNew && (
|
||||
<Action>
|
||||
<TemplatesMenu
|
||||
isCompact={isCompact}
|
||||
document={document}
|
||||
onSelectTemplate={onSelectTemplate}
|
||||
/>
|
||||
</Action>
|
||||
)}
|
||||
{(isEditing || !user?.separateEditMode) &&
|
||||
!isTemplate &&
|
||||
isNew &&
|
||||
can.update && (
|
||||
<Action>
|
||||
<TemplatesMenu
|
||||
isCompact={isCompact}
|
||||
document={document}
|
||||
onSelectTemplate={onSelectTemplate}
|
||||
/>
|
||||
</Action>
|
||||
)}
|
||||
{!isEditing && !isRevision && !isTemplate && can.update && (
|
||||
<Action>
|
||||
<ShareButton document={document} />
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import isEqual from "fast-deep-equal";
|
||||
import orderBy from "lodash/orderBy";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory, useRouteMatch } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import { Pagination } from "@shared/constants";
|
||||
import { RevisionHelper } from "@shared/utils/RevisionHelper";
|
||||
import Document from "~/models/Document";
|
||||
import Event from "~/models/Event";
|
||||
import EventModel from "~/models/Event";
|
||||
import Revision from "~/models/Revision";
|
||||
import Empty from "~/components/Empty";
|
||||
import { DocumentEvent, type Event } from "~/components/EventListItem";
|
||||
import PaginatedEventList from "~/components/PaginatedEventList";
|
||||
import useKeyDown from "~/hooks/useKeyDown";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
@@ -14,21 +19,153 @@ import useStores from "~/hooks/useStores";
|
||||
import { documentPath } from "~/utils/routeHelpers";
|
||||
import Sidebar from "./SidebarLayout";
|
||||
|
||||
const EMPTY_ARRAY: Event<Document>[] = [];
|
||||
const DocumentEvents = [
|
||||
"documents.publish",
|
||||
"documents.unpublish",
|
||||
"documents.archive",
|
||||
"documents.unarchive",
|
||||
"documents.delete",
|
||||
"documents.restore",
|
||||
"documents.add_user",
|
||||
"documents.remove_user",
|
||||
"documents.move",
|
||||
];
|
||||
|
||||
function History() {
|
||||
const { events, documents } = useStores();
|
||||
const { events, documents, revisions } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const match = useRouteMatch<{ documentSlug: string }>();
|
||||
const history = useHistory();
|
||||
const sidebarContext = useLocationSidebarContext();
|
||||
const document = documents.getByUrl(match.params.documentSlug);
|
||||
|
||||
const eventsInDocument = document
|
||||
? events.filter({ documentId: document.id })
|
||||
: EMPTY_ARRAY;
|
||||
const [, setForceRender] = React.useState(0);
|
||||
const offset = React.useMemo(() => ({ revisions: 0, events: 0 }), []);
|
||||
|
||||
const onCloseHistory = () => {
|
||||
const toEvent = React.useCallback(
|
||||
(data: Revision | EventModel<Document>): Event => {
|
||||
if (data instanceof Revision) {
|
||||
return {
|
||||
id: data.id,
|
||||
name: "revisions.create",
|
||||
actorId: data.createdBy.id,
|
||||
createdAt: data.createdAt,
|
||||
latest: false,
|
||||
} satisfies Event;
|
||||
}
|
||||
|
||||
return {
|
||||
id: data.id,
|
||||
name: data.name as DocumentEvent["name"],
|
||||
actorId: data.actorId,
|
||||
userId: data.userId,
|
||||
createdAt: data.createdAt,
|
||||
} satisfies Event;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const fetchHistory = React.useCallback(async () => {
|
||||
if (!document) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const [revisionsArr, eventsArr] = await Promise.all([
|
||||
revisions.fetchPage({
|
||||
documentId: document.id,
|
||||
offset: offset.revisions,
|
||||
limit: Pagination.defaultLimit,
|
||||
}),
|
||||
events.fetchPage({
|
||||
events: DocumentEvents,
|
||||
documentId: document.id,
|
||||
offset: offset.events,
|
||||
limit: Pagination.defaultLimit,
|
||||
}),
|
||||
]);
|
||||
|
||||
const pageEvents = orderBy(
|
||||
[...revisionsArr, ...eventsArr].map(toEvent),
|
||||
"createdAt",
|
||||
"desc"
|
||||
).slice(0, Pagination.defaultLimit);
|
||||
|
||||
const revisionsCount = pageEvents.filter(
|
||||
(event) => event.name === "revisions.create"
|
||||
).length;
|
||||
|
||||
offset.revisions += revisionsCount;
|
||||
offset.events += pageEvents.length - revisionsCount;
|
||||
|
||||
// needed to re-render after mobx store and offset is updated
|
||||
setForceRender((s) => ++s);
|
||||
|
||||
return pageEvents;
|
||||
}, [document, revisions, events, toEvent, offset]);
|
||||
|
||||
const revisionEvents = React.useMemo(() => {
|
||||
if (!document) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const latestRevisionId = RevisionHelper.latestId(document.id);
|
||||
return revisions
|
||||
.filter(
|
||||
(revision: Revision) =>
|
||||
revision.id !== latestRevisionId &&
|
||||
revision.documentId === document.id
|
||||
)
|
||||
.slice(0, offset.revisions)
|
||||
.map(toEvent);
|
||||
}, [document, revisions, offset.revisions, toEvent]);
|
||||
|
||||
const nonRevisionEvents = React.useMemo(
|
||||
() =>
|
||||
document
|
||||
? events
|
||||
.filter({ documentId: document.id })
|
||||
.slice(0, offset.events)
|
||||
.map(toEvent)
|
||||
: [],
|
||||
[document, events, offset.events, toEvent]
|
||||
);
|
||||
|
||||
const mergedEvents = React.useMemo(() => {
|
||||
const merged = orderBy(
|
||||
[...revisionEvents, ...nonRevisionEvents],
|
||||
"createdAt",
|
||||
"desc"
|
||||
);
|
||||
|
||||
const latestRevisionEvent = merged.find(
|
||||
(event) => event.name === "revisions.create"
|
||||
);
|
||||
|
||||
if (latestRevisionEvent && document) {
|
||||
const latestRevision = revisions.get(latestRevisionEvent.id);
|
||||
|
||||
const isDocUpdated =
|
||||
latestRevision?.title !== document.title ||
|
||||
!isEqual(latestRevision.data, document.data);
|
||||
|
||||
if (isDocUpdated) {
|
||||
revisions.remove(RevisionHelper.latestId(document.id));
|
||||
merged.unshift({
|
||||
id: RevisionHelper.latestId(document.id),
|
||||
name: "revisions.create",
|
||||
createdAt: document.updatedAt,
|
||||
actorId: document.updatedBy?.id ?? "",
|
||||
latest: true,
|
||||
});
|
||||
} else if (latestRevisionEvent) {
|
||||
latestRevisionEvent.latest = true;
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}, [revisions, document, revisionEvents, nonRevisionEvents]);
|
||||
|
||||
const onCloseHistory = React.useCallback(() => {
|
||||
if (document) {
|
||||
history.push({
|
||||
pathname: documentPath(document),
|
||||
@@ -37,30 +174,7 @@ function History() {
|
||||
} else {
|
||||
history.goBack();
|
||||
}
|
||||
};
|
||||
|
||||
const items = React.useMemo(() => {
|
||||
if (
|
||||
eventsInDocument[0] &&
|
||||
document &&
|
||||
eventsInDocument[0].createdAt !== document.updatedAt
|
||||
) {
|
||||
eventsInDocument.unshift(
|
||||
new Event(
|
||||
{
|
||||
id: RevisionHelper.latestId(document.id),
|
||||
name: "revisions.create",
|
||||
documentId: document.id,
|
||||
createdAt: document.updatedAt,
|
||||
actor: document.updatedBy,
|
||||
},
|
||||
events
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return eventsInDocument;
|
||||
}, [eventsInDocument, events, document]);
|
||||
}, [history, document, sidebarContext]);
|
||||
|
||||
useKeyDown("Escape", onCloseHistory);
|
||||
|
||||
@@ -69,11 +183,8 @@ function History() {
|
||||
{document ? (
|
||||
<PaginatedEventList
|
||||
aria-label={t("History")}
|
||||
fetch={events.fetchPage}
|
||||
events={items}
|
||||
options={{
|
||||
documentId: document.id,
|
||||
}}
|
||||
fetch={fetchHistory}
|
||||
events={mergedEvents}
|
||||
document={document}
|
||||
empty={<EmptyHistory>{t("No history yet")}</EmptyHistory>}
|
||||
/>
|
||||
|
||||
@@ -105,7 +105,7 @@ function Message({ notice }: { notice: string }) {
|
||||
case "authentication-provider-disabled":
|
||||
return (
|
||||
<Trans>
|
||||
Authentication failed – this login method was disabled by a team
|
||||
Authentication failed – this login method was disabled by a workspace
|
||||
admin.
|
||||
</Trans>
|
||||
);
|
||||
|
||||
@@ -35,7 +35,7 @@ function Export() {
|
||||
<Heading>{t("Export")}</Heading>
|
||||
<Text as="p" type="secondary">
|
||||
<Trans
|
||||
defaults="A full export might take some time, consider exporting a single document or collection. The exported data is a zip of your documents in Markdown format. You may leave this page once the export has started – if you have notifications enabled, we will email a link to <em>{{ userEmail }}</em> when it’s complete."
|
||||
defaults="A full export might take some time, consider exporting a single document or collection. You may leave this page once the export has started – if you have notifications enabled, we will email a link to <em>{{ userEmail }}</em> when it’s complete."
|
||||
values={{
|
||||
userEmail: user.email,
|
||||
}}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { transparentize } from "polished";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { s } from "@shared/styles";
|
||||
|
||||
/**
|
||||
@@ -8,8 +9,9 @@ import { s } from "@shared/styles";
|
||||
export const ActionRow = styled.div`
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
padding: 16px 50vw;
|
||||
margin: 0 -50vw;
|
||||
width: 100vw;
|
||||
padding: 16px 12px;
|
||||
margin-left: -12px;
|
||||
|
||||
background: ${s("background")};
|
||||
|
||||
@@ -17,4 +19,8 @@ export const ActionRow = styled.div`
|
||||
backdrop-filter: blur(20px);
|
||||
background: ${(props) => transparentize(0.2, props.theme.background)};
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
width: auto;
|
||||
`}
|
||||
`;
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
CollectionPermission,
|
||||
CollectionStatusFilter,
|
||||
FileOperationFormat,
|
||||
SubscriptionType,
|
||||
} from "@shared/types";
|
||||
import Collection from "~/models/Collection";
|
||||
import { PaginationParams, Properties } from "~/types";
|
||||
@@ -213,6 +214,20 @@ export default class CollectionsStore extends Store<Collection> {
|
||||
await star?.delete();
|
||||
};
|
||||
|
||||
subscribe = (collection: Collection) =>
|
||||
this.rootStore.subscriptions.create({
|
||||
collectionId: collection.id,
|
||||
event: SubscriptionType.Document,
|
||||
});
|
||||
|
||||
unsubscribe = (collection: Collection) => {
|
||||
const subscription = this.rootStore.subscriptions.getByCollectionId(
|
||||
collection.id
|
||||
);
|
||||
|
||||
return subscription?.delete();
|
||||
};
|
||||
|
||||
@computed
|
||||
get navigationNodes() {
|
||||
return this.orderedData.map((collection) => collection.asNavigationNode);
|
||||
|
||||
@@ -5,11 +5,12 @@ import find from "lodash/find";
|
||||
import omitBy from "lodash/omitBy";
|
||||
import orderBy from "lodash/orderBy";
|
||||
import { observable, action, computed, runInAction } from "mobx";
|
||||
import type {
|
||||
DateFilter,
|
||||
NavigationNode,
|
||||
PublicTeam,
|
||||
StatusFilter,
|
||||
import {
|
||||
SubscriptionType,
|
||||
type DateFilter,
|
||||
type NavigationNode,
|
||||
type PublicTeam,
|
||||
type StatusFilter,
|
||||
} from "@shared/types";
|
||||
import { subtractDate } from "@shared/utils/date";
|
||||
import { bytesToHumanReadable } from "@shared/utils/files";
|
||||
@@ -63,6 +64,7 @@ export default class DocumentsStore extends Store<Document> {
|
||||
".md",
|
||||
".doc",
|
||||
".docx",
|
||||
".tsv",
|
||||
"text/csv",
|
||||
"text/markdown",
|
||||
"text/plain",
|
||||
@@ -342,18 +344,8 @@ export default class DocumentsStore extends Store<Document> {
|
||||
};
|
||||
|
||||
@action
|
||||
fetchArchived = async (options?: PaginationParams): Promise<Document[]> => {
|
||||
const archivedInResponse = await this.fetchNamedPage("archived", options);
|
||||
const archivedInMemory = this.archived;
|
||||
|
||||
archivedInMemory.forEach((docInMemory) => {
|
||||
!archivedInResponse.find(
|
||||
(docInResponse) => docInResponse.id === docInMemory.id
|
||||
) && this.remove(docInMemory.id);
|
||||
});
|
||||
|
||||
return archivedInResponse;
|
||||
};
|
||||
fetchArchived = async (options?: PaginationParams): Promise<Document[]> =>
|
||||
this.fetchNamedPage("archived", options);
|
||||
|
||||
@action
|
||||
fetchDeleted = async (options?: PaginationParams): Promise<Document[]> =>
|
||||
@@ -775,17 +767,30 @@ export default class DocumentsStore extends Store<Document> {
|
||||
};
|
||||
|
||||
@action
|
||||
unpublish = async (document: Document) => {
|
||||
unpublish = async (
|
||||
document: Document,
|
||||
options: { detach?: boolean } = {
|
||||
detach: false,
|
||||
}
|
||||
) => {
|
||||
const res = await client.post("/documents.unpublish", {
|
||||
id: document.id,
|
||||
...options,
|
||||
});
|
||||
|
||||
runInAction("Document#unpublish", () => {
|
||||
invariant(res?.data, "Data should be available");
|
||||
// unpublishing could sometimes detach the document from the collection.
|
||||
// so, get the collection id before data is updated.
|
||||
const collectionId = document.collectionId;
|
||||
|
||||
document.updateData(res.data);
|
||||
this.addPolicies(res.policies);
|
||||
const collection = this.getCollectionForDocument(document);
|
||||
void collection?.fetchDocuments({ force: true });
|
||||
|
||||
if (collectionId) {
|
||||
const collection = this.rootStore.collections.get(collectionId);
|
||||
collection?.removeDocument(document.id);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -813,12 +818,12 @@ export default class DocumentsStore extends Store<Document> {
|
||||
subscribe = (document: Document) =>
|
||||
this.rootStore.subscriptions.create({
|
||||
documentId: document.id,
|
||||
event: "documents.update",
|
||||
event: SubscriptionType.Document,
|
||||
});
|
||||
|
||||
unsubscribe = (userId: string, document: Document) => {
|
||||
const subscription = this.rootStore.subscriptions.orderedData.find(
|
||||
(s) => s.documentId === document.id && s.userId === userId
|
||||
unsubscribe = (document: Document) => {
|
||||
const subscription = this.rootStore.subscriptions.getByDocumentId(
|
||||
document.id
|
||||
);
|
||||
|
||||
return subscription?.delete();
|
||||
|
||||
@@ -3,6 +3,7 @@ import { action, runInAction, computed } from "mobx";
|
||||
import Pin from "~/models/Pin";
|
||||
import { PaginationParams } from "~/types";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import { AuthorizationError, NotFoundError } from "~/utils/errors";
|
||||
import RootStore from "./RootStore";
|
||||
import Store from "./base/Store";
|
||||
|
||||
@@ -13,6 +14,41 @@ export default class PinsStore extends Store<Pin> {
|
||||
super(rootStore, Pin);
|
||||
}
|
||||
|
||||
@action
|
||||
async fetchOne({
|
||||
documentId,
|
||||
collectionId,
|
||||
}: {
|
||||
documentId: string;
|
||||
collectionId: string | null;
|
||||
}) {
|
||||
const pin = this.orderedData.find(
|
||||
(p) => p.documentId === documentId && p.collectionId === collectionId
|
||||
);
|
||||
|
||||
if (pin) {
|
||||
return pin;
|
||||
}
|
||||
|
||||
this.isFetching = true;
|
||||
|
||||
try {
|
||||
const res = await client.post(`/${this.apiEndpoint}.info`, {
|
||||
documentId,
|
||||
collectionId,
|
||||
});
|
||||
invariant(res?.data, "Data should be available");
|
||||
return this.add(res.data);
|
||||
} catch (err) {
|
||||
if (err instanceof AuthorizationError || err instanceof NotFoundError) {
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
this.isFetching = false;
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
fetchPage = async (params?: FetchParams | undefined): Promise<Pin[]> => {
|
||||
this.isFetching = true;
|
||||
|
||||
@@ -58,7 +58,7 @@ export default class RevisionsStore extends Store<Revision> {
|
||||
|
||||
@action
|
||||
fetchPage = async (
|
||||
options: PaginationParams | undefined
|
||||
options: { documentId: string } & (PaginationParams | undefined)
|
||||
): Promise<Revision[]> => {
|
||||
this.isFetching = true;
|
||||
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import invariant from "invariant";
|
||||
import { action } from "mobx";
|
||||
import { SubscriptionType } from "@shared/types";
|
||||
import Subscription from "~/models/Subscription";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import { AuthorizationError, NotFoundError } from "~/utils/errors";
|
||||
import RootStore from "./RootStore";
|
||||
import Store, { RPCAction } from "./base/Store";
|
||||
|
||||
@@ -8,4 +13,42 @@ export default class SubscriptionsStore extends Store<Subscription> {
|
||||
constructor(rootStore: RootStore) {
|
||||
super(rootStore, Subscription);
|
||||
}
|
||||
|
||||
@action
|
||||
async fetchOne(
|
||||
options: { event: SubscriptionType } & (
|
||||
| { documentId: string }
|
||||
| { collectionId: string }
|
||||
)
|
||||
) {
|
||||
const subscription =
|
||||
"collectionId" in options
|
||||
? this.getByCollectionId(options.collectionId)
|
||||
: this.getByDocumentId(options.documentId);
|
||||
|
||||
if (subscription) {
|
||||
return subscription;
|
||||
}
|
||||
|
||||
this.isFetching = true;
|
||||
|
||||
try {
|
||||
const res = await client.post(`/${this.apiEndpoint}.info`, options);
|
||||
invariant(res?.data, "Data should be available");
|
||||
return this.add(res.data);
|
||||
} catch (err) {
|
||||
if (err instanceof AuthorizationError || err instanceof NotFoundError) {
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
this.isFetching = false;
|
||||
}
|
||||
}
|
||||
|
||||
getByDocumentId = (documentId: string): Subscription | undefined =>
|
||||
this.find({ documentId });
|
||||
|
||||
getByCollectionId = (collectionId: string): Subscription | undefined =>
|
||||
this.find({ collectionId });
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ export type MenuItemButton = {
|
||||
selected?: boolean;
|
||||
disabled?: boolean;
|
||||
icon?: React.ReactNode;
|
||||
tooltip?: React.ReactChild;
|
||||
};
|
||||
|
||||
export type MenuItemWithChildren = {
|
||||
|
||||
+20
-19
@@ -48,17 +48,17 @@
|
||||
"> 0.25%, not dead"
|
||||
],
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.740.0",
|
||||
"@aws-sdk/lib-storage": "3.740.0",
|
||||
"@aws-sdk/s3-presigned-post": "3.740.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.740.0",
|
||||
"@aws-sdk/signature-v4-crt": "^3.740.0",
|
||||
"@babel/core": "^7.26.7",
|
||||
"@aws-sdk/client-s3": "3.750.0",
|
||||
"@aws-sdk/lib-storage": "3.750.0",
|
||||
"@aws-sdk/s3-presigned-post": "3.750.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.750.0",
|
||||
"@aws-sdk/signature-v4-crt": "^3.750.0",
|
||||
"@babel/core": "^7.26.9",
|
||||
"@babel/plugin-proposal-decorators": "^7.25.9",
|
||||
"@babel/plugin-transform-class-properties": "^7.25.9",
|
||||
"@babel/plugin-transform-destructuring": "^7.25.9",
|
||||
"@babel/plugin-transform-regenerator": "^7.25.9",
|
||||
"@babel/preset-env": "^7.26.7",
|
||||
"@babel/preset-env": "^7.26.9",
|
||||
"@babel/preset-react": "^7.26.3",
|
||||
"@benrbray/prosemirror-math": "^0.2.2",
|
||||
"@bull-board/api": "^4.2.2",
|
||||
@@ -118,7 +118,7 @@
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fetch-retry": "^5.0.6",
|
||||
"fetch-with-proxy": "^3.0.1",
|
||||
"form-data": "^4.0.0",
|
||||
"form-data": "^4.0.2",
|
||||
"fractional-index": "^1.0.0",
|
||||
"framer-motion": "^4.1.17",
|
||||
"fs-extra": "^11.2.0",
|
||||
@@ -136,7 +136,7 @@
|
||||
"jszip": "^3.10.1",
|
||||
"katex": "^0.16.21",
|
||||
"kbar": "0.1.0-beta.41",
|
||||
"koa": "^2.15.3",
|
||||
"koa": "^2.15.4",
|
||||
"koa-body": "^6.0.1",
|
||||
"koa-compress": "^5.1.1",
|
||||
"koa-helmet": "^6.1.0",
|
||||
@@ -159,7 +159,7 @@
|
||||
"mobx-utils": "^4.0.1",
|
||||
"natural-sort": "^1.0.0",
|
||||
"node-fetch": "2.7.0",
|
||||
"nodemailer": "^6.9.16",
|
||||
"nodemailer": "^6.10.0",
|
||||
"octokit": "^3.2.1",
|
||||
"outline-icons": "^3.10.0",
|
||||
"oy-vey": "^0.12.1",
|
||||
@@ -188,7 +188,7 @@
|
||||
"prosemirror-transform": "1.10.0",
|
||||
"prosemirror-view": "^1.37.1",
|
||||
"query-string": "^7.1.3",
|
||||
"randomstring": "1.3.0",
|
||||
"randomstring": "1.3.1",
|
||||
"rate-limiter-flexible": "^2.4.2",
|
||||
"react": "^17.0.2",
|
||||
"react-avatar-editor": "^13.0.2",
|
||||
@@ -201,7 +201,7 @@
|
||||
"react-helmet-async": "^2.0.5",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"react-i18next": "^12.3.1",
|
||||
"react-medium-image-zoom": "5.2.10",
|
||||
"react-medium-image-zoom": "5.2.13",
|
||||
"react-merge-refs": "^2.1.1",
|
||||
"react-portal": "^4.2.2",
|
||||
"react-router-dom": "^5.3.4",
|
||||
@@ -217,7 +217,7 @@
|
||||
"rfc6902": "^5.1.1",
|
||||
"sanitize-filename": "^1.6.3",
|
||||
"scroll-into-view-if-needed": "^3.1.0",
|
||||
"semver": "^7.6.2",
|
||||
"semver": "^7.7.1",
|
||||
"sequelize": "^6.37.3",
|
||||
"sequelize-cli": "^6.6.2",
|
||||
"sequelize-encrypted": "^1.0.0",
|
||||
@@ -237,11 +237,12 @@
|
||||
"tiny-cookie": "^2.5.1",
|
||||
"tmp": "^0.2.3",
|
||||
"turndown": "^7.2.0",
|
||||
"ukkonen": "^2.1.0",
|
||||
"umzug": "^3.8.2",
|
||||
"utility-types": "^3.11.0",
|
||||
"uuid": "^8.3.2",
|
||||
"validator": "13.12.0",
|
||||
"vite": "^5.4.12",
|
||||
"vite": "^5.4.14",
|
||||
"vite-plugin-pwa": "^0.20.3",
|
||||
"winston": "^3.17.0",
|
||||
"ws": "^7.5.10",
|
||||
@@ -256,14 +257,14 @@
|
||||
"@babel/cli": "^7.26.4",
|
||||
"@babel/preset-typescript": "^7.26.0",
|
||||
"@faker-js/faker": "^8.4.1",
|
||||
"@relative-ci/agent": "^4.2.13",
|
||||
"@relative-ci/agent": "^4.2.14",
|
||||
"@testing-library/react": "^12.0.0",
|
||||
"@types/addressparser": "^1.0.3",
|
||||
"@types/body-scroll-lock": "^3.1.2",
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"@types/diff": "^5.0.9",
|
||||
"@types/dotenv": "^8.2.3",
|
||||
"@types/emoji-regex": "^9.2.0",
|
||||
"@types/emoji-regex": "^9.2.2",
|
||||
"@types/escape-html": "^1.0.4",
|
||||
"@types/express-useragent": "^1.0.5",
|
||||
"@types/formidable": "^2.0.6",
|
||||
@@ -333,7 +334,7 @@
|
||||
"discord-api-types": "^0.37.102",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-prettier": "^8.10.0",
|
||||
"eslint-import-resolver-typescript": "^3.7.0",
|
||||
"eslint-import-resolver-typescript": "^3.8.0",
|
||||
"eslint-plugin-es": "^4.1.0",
|
||||
"eslint-plugin-import": "^2.31.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
@@ -355,7 +356,7 @@
|
||||
"rimraf": "^2.5.4",
|
||||
"rollup-plugin-webpack-stats": "^2.0.1",
|
||||
"terser": "^5.37.0",
|
||||
"typescript": "^5.7.2",
|
||||
"typescript": "^5.7.3",
|
||||
"vite-plugin-static-copy": "^0.17.0",
|
||||
"yarn-deduplicate": "^6.0.2"
|
||||
},
|
||||
@@ -369,5 +370,5 @@
|
||||
"qs": "6.9.7",
|
||||
"rollup": "^4.5.1"
|
||||
},
|
||||
"version": "0.81.0"
|
||||
"version": "0.82.0"
|
||||
}
|
||||
|
||||
@@ -4,9 +4,15 @@ import path from "path";
|
||||
import FormData from "form-data";
|
||||
import { ensureDirSync } from "fs-extra";
|
||||
import { v4 as uuidV4 } from "uuid";
|
||||
import { FileOperationState, FileOperationType } from "@shared/types";
|
||||
import env from "@server/env";
|
||||
import { Buckets } from "@server/models/helpers/AttachmentHelper";
|
||||
import FileStorage from "@server/storage/files";
|
||||
import { buildAttachment, buildUser } from "@server/test/factories";
|
||||
import {
|
||||
buildAttachment,
|
||||
buildFileOperation,
|
||||
buildUser,
|
||||
} from "@server/test/factories";
|
||||
import { getTestServer } from "@server/test/support";
|
||||
|
||||
const server = getTestServer();
|
||||
@@ -236,7 +242,16 @@ describe("#files.get", () => {
|
||||
it("should succeed with status 200 ok when file is requested using signature", async () => {
|
||||
const user = await buildUser();
|
||||
const fileName = "images.docx";
|
||||
const key = path.join("uploads", user.id, uuidV4(), fileName);
|
||||
const { key } = await buildAttachment(
|
||||
{
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
contentType:
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
acl: "private",
|
||||
},
|
||||
fileName
|
||||
);
|
||||
const signedUrl = await FileStorage.getSignedUrl(key);
|
||||
|
||||
ensureDirSync(
|
||||
@@ -262,6 +277,13 @@ describe("#files.get", () => {
|
||||
it("should succeed with status 200 ok when avatar is requested using key", async () => {
|
||||
const user = await buildUser();
|
||||
const key = path.join("avatars", user.id, uuidV4());
|
||||
await buildAttachment({
|
||||
key,
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
contentType: "image/jpg",
|
||||
acl: "public-read",
|
||||
});
|
||||
|
||||
ensureDirSync(
|
||||
path.dirname(path.join(env.FILE_STORAGE_LOCAL_ROOT_DIR, key))
|
||||
@@ -274,7 +296,40 @@ describe("#files.get", () => {
|
||||
|
||||
const res = await server.get(`/api/files.get?key=${key}`);
|
||||
expect(res.status).toEqual(200);
|
||||
expect(res.headers.get("Content-Type")).toEqual("application/octet-stream");
|
||||
expect(res.headers.get("Content-Type")).toEqual("image/jpg");
|
||||
expect(res.headers.get("Content-Disposition")).toEqual("attachment");
|
||||
});
|
||||
|
||||
it("should succeed with status 200 ok when exported file is requested using signature", async () => {
|
||||
const user = await buildUser();
|
||||
const fileName = "export-markdown.zip";
|
||||
const key = `${Buckets.uploads}/${user.teamId}/${uuidV4()}/${fileName}`;
|
||||
|
||||
await buildFileOperation({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
type: FileOperationType.Export,
|
||||
state: FileOperationState.Complete,
|
||||
key,
|
||||
});
|
||||
|
||||
ensureDirSync(
|
||||
path.dirname(path.join(env.FILE_STORAGE_LOCAL_ROOT_DIR, key))
|
||||
);
|
||||
|
||||
copyFileSync(
|
||||
path.resolve(__dirname, "..", "test", "fixtures", fileName),
|
||||
path.join(env.FILE_STORAGE_LOCAL_ROOT_DIR, key)
|
||||
);
|
||||
|
||||
const signedUrl = await FileStorage.getSignedUrl(key);
|
||||
const url = new URL(signedUrl);
|
||||
const res = await server.get(url.pathname + url.search);
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(res.headers.get("Content-Type")).toEqual("application/zip");
|
||||
expect(res.headers.get("Content-Disposition")).toEqual(
|
||||
'attachment; filename="export-markdown.zip"'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import env from "@server/env";
|
||||
import {
|
||||
AuthenticationError,
|
||||
AuthorizationError,
|
||||
NotFoundError,
|
||||
ValidationError,
|
||||
} from "@server/errors";
|
||||
import auth from "@server/middlewares/authentication";
|
||||
@@ -77,19 +78,25 @@ router.get(
|
||||
const { isPublicBucket, fileName } = AttachmentHelper.parseKey(key);
|
||||
const skipAuthorize = isPublicBucket || isSignedRequest;
|
||||
const cacheHeader = "max-age=604800, immutable";
|
||||
let contentType =
|
||||
(fileName ? mime.lookup(fileName) : undefined) ||
|
||||
"application/octet-stream";
|
||||
|
||||
const attachment = await Attachment.findOne({
|
||||
where: { key },
|
||||
});
|
||||
|
||||
// Attachment is requested with a key, but it was not found
|
||||
if (!attachment && !!ctx.input.query.key) {
|
||||
throw NotFoundError();
|
||||
}
|
||||
|
||||
if (!skipAuthorize) {
|
||||
const attachment = await Attachment.findOne({
|
||||
where: { key },
|
||||
rejectOnEmpty: true,
|
||||
});
|
||||
authorize(actor, "read", attachment);
|
||||
contentType = attachment.contentType;
|
||||
}
|
||||
|
||||
const contentType =
|
||||
attachment?.contentType ||
|
||||
(fileName ? mime.lookup(fileName) : undefined) ||
|
||||
"application/octet-stream";
|
||||
|
||||
ctx.set("Accept-Ranges", "bytes");
|
||||
ctx.set("Cache-Control", cacheHeader);
|
||||
ctx.set("Content-Type", contentType);
|
||||
|
||||
Binary file not shown.
@@ -10,7 +10,7 @@ import BaseTask, {
|
||||
type Props = Record<string, never>;
|
||||
|
||||
export default class CleanupWebhookDeliveriesTask extends BaseTask<Props> {
|
||||
static cron = TaskSchedule.Daily;
|
||||
static cron = TaskSchedule.Day;
|
||||
|
||||
public async perform() {
|
||||
Logger.info("task", `Deleting WebhookDeliveries older than one week…`);
|
||||
|
||||
@@ -190,6 +190,56 @@ describe("accountProvisioner", () => {
|
||||
expect(error).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should prioritize enabled authentication provider", async () => {
|
||||
const existingTeam = await buildTeam();
|
||||
const existingProviders = await existingTeam.$get(
|
||||
"authenticationProviders"
|
||||
);
|
||||
|
||||
const team2 = await buildTeam();
|
||||
|
||||
const providers = await team2.$get("authenticationProviders");
|
||||
const authenticationProvider = providers[0];
|
||||
await authenticationProvider.update({
|
||||
enabled: false,
|
||||
providerId: existingProviders[0].providerId,
|
||||
});
|
||||
|
||||
const existing = await buildUser({
|
||||
teamId: existingTeam.id,
|
||||
});
|
||||
const authentications = await existing.$get("authentications");
|
||||
const authentication = authentications[0];
|
||||
const { isNewUser, isNewTeam } = await accountProvisioner({
|
||||
ip,
|
||||
user: {
|
||||
name: existing.name,
|
||||
email: existing.email!,
|
||||
avatarUrl: existing.avatarUrl,
|
||||
},
|
||||
team: {
|
||||
name: existingTeam.name,
|
||||
avatarUrl: existingTeam.avatarUrl,
|
||||
subdomain: faker.internet.domainWord(),
|
||||
},
|
||||
authenticationProvider: {
|
||||
name: authenticationProvider.name,
|
||||
providerId: authenticationProvider.providerId,
|
||||
},
|
||||
authentication: {
|
||||
providerId: authentication.providerId,
|
||||
accessToken: "123",
|
||||
scopes: ["read"],
|
||||
},
|
||||
});
|
||||
const auth = await UserAuthentication.findByPk(authentication.id);
|
||||
expect(auth?.accessToken).toEqual("123");
|
||||
expect(auth?.scopes.length).toEqual(1);
|
||||
expect(auth?.scopes[0]).toEqual("read");
|
||||
expect(isNewTeam).toEqual(false);
|
||||
expect(isNewUser).toEqual(false);
|
||||
});
|
||||
|
||||
it("should throw an error when the domain is not allowed", async () => {
|
||||
const existingTeam = await buildTeam();
|
||||
const admin = await buildAdmin({ teamId: existingTeam.id });
|
||||
|
||||
@@ -102,7 +102,7 @@ async function accountProvisioner({
|
||||
if (err.id === "invalid_authentication") {
|
||||
const authenticationProvider = await AuthenticationProvider.findOne({
|
||||
where: {
|
||||
name: authenticationProviderParams.name, // example: "google"
|
||||
name: authenticationProviderParams.name,
|
||||
teamId: teamParams.teamId,
|
||||
},
|
||||
include: [
|
||||
@@ -112,6 +112,7 @@ async function accountProvisioner({
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
order: [["enabled", "DESC"]],
|
||||
});
|
||||
|
||||
if (authenticationProvider) {
|
||||
|
||||
@@ -62,12 +62,13 @@ export default async function pinCreator({
|
||||
index = fractionalIndex(pins.length ? pins[0].index : null, null);
|
||||
}
|
||||
|
||||
const pin = await Pin.createWithCtx(ctx, {
|
||||
createdById: user.id,
|
||||
teamId: user.teamId,
|
||||
collectionId,
|
||||
documentId,
|
||||
index,
|
||||
const [pin] = await Pin.findOrCreateWithCtx(ctx, {
|
||||
where: {
|
||||
collectionId: collectionId ?? null,
|
||||
documentId,
|
||||
teamId: user.teamId,
|
||||
},
|
||||
defaults: { index, createdById: user.id },
|
||||
});
|
||||
|
||||
return pin;
|
||||
|
||||
@@ -1,14 +1,51 @@
|
||||
import { SubscriptionType } from "@shared/types";
|
||||
import { createContext } from "@server/context";
|
||||
import { Subscription, Event } from "@server/models";
|
||||
import { sequelize } from "@server/storage/database";
|
||||
import { buildDocument, buildUser } from "@server/test/factories";
|
||||
import {
|
||||
buildCollection,
|
||||
buildDocument,
|
||||
buildUser,
|
||||
} from "@server/test/factories";
|
||||
import subscriptionCreator from "./subscriptionCreator";
|
||||
|
||||
describe("subscriptionCreator", () => {
|
||||
const ip = "127.0.0.1";
|
||||
const subscribedEvent = "documents.update";
|
||||
const subscribedEvent = SubscriptionType.Document;
|
||||
|
||||
it("should create a subscription", async () => {
|
||||
it("should create a document subscription for the whole collection", async () => {
|
||||
const user = await buildUser();
|
||||
|
||||
const collection = await buildCollection({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
|
||||
const subscription = await sequelize.transaction(async (transaction) =>
|
||||
subscriptionCreator({
|
||||
ctx: createContext({ user, transaction, ip }),
|
||||
collectionId: collection.id,
|
||||
event: SubscriptionType.Document,
|
||||
})
|
||||
);
|
||||
|
||||
const event = await Event.findOne({
|
||||
where: {
|
||||
teamId: user.teamId,
|
||||
},
|
||||
});
|
||||
|
||||
expect(subscription.collectionId).toEqual(collection.id);
|
||||
expect(subscription.documentId).toBeNull();
|
||||
expect(subscription.userId).toEqual(user.id);
|
||||
expect(event?.name).toEqual("subscriptions.create");
|
||||
expect(event?.modelId).toEqual(subscription.id);
|
||||
expect(event?.actorId).toEqual(subscription.userId);
|
||||
expect(event?.userId).toEqual(subscription.userId);
|
||||
expect(event?.collectionId).toEqual(subscription.collectionId);
|
||||
});
|
||||
|
||||
it("should create a document subscription", async () => {
|
||||
const user = await buildUser();
|
||||
|
||||
const document = await buildDocument({
|
||||
@@ -31,6 +68,7 @@ describe("subscriptionCreator", () => {
|
||||
});
|
||||
|
||||
expect(subscription.documentId).toEqual(document.id);
|
||||
expect(subscription.collectionId).toBeNull();
|
||||
expect(subscription.userId).toEqual(user.id);
|
||||
expect(event?.name).toEqual("subscriptions.create");
|
||||
expect(event?.modelId).toEqual(subscription.id);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { WhereOptions } from "sequelize";
|
||||
import { SubscriptionType } from "@shared/types";
|
||||
import { createContext } from "@server/context";
|
||||
import { Subscription, Document } from "@server/models";
|
||||
import { sequelize } from "@server/storage/database";
|
||||
@@ -8,8 +10,10 @@ type Props = {
|
||||
ctx: APIContext;
|
||||
/** The document to subscribe to */
|
||||
documentId?: string;
|
||||
/** The collection to subscribe to */
|
||||
collectionId?: string;
|
||||
/** Event to subscribe to */
|
||||
event: string;
|
||||
event: SubscriptionType;
|
||||
/** Whether the subscription should be restored if it exists in a deleted state */
|
||||
resubscribe?: boolean;
|
||||
};
|
||||
@@ -22,16 +26,27 @@ type Props = {
|
||||
export default async function subscriptionCreator({
|
||||
ctx,
|
||||
documentId,
|
||||
collectionId,
|
||||
event,
|
||||
resubscribe = true,
|
||||
}: Props): Promise<Subscription> {
|
||||
const { user } = ctx.context.auth;
|
||||
|
||||
const where: WhereOptions<Subscription> = {
|
||||
userId: user.id,
|
||||
event,
|
||||
};
|
||||
|
||||
if (documentId) {
|
||||
where.documentId = documentId;
|
||||
}
|
||||
|
||||
if (collectionId) {
|
||||
where.collectionId = collectionId;
|
||||
}
|
||||
|
||||
const [subscription] = await Subscription.findOrCreateWithCtx(ctx, {
|
||||
where: {
|
||||
userId: user.id,
|
||||
documentId,
|
||||
event,
|
||||
},
|
||||
where,
|
||||
paranoid: false, // Previous subscriptions are soft-deleted, we want to know about them here.
|
||||
});
|
||||
|
||||
@@ -68,7 +83,7 @@ export const createSubscriptionsForDocument = async (
|
||||
transaction,
|
||||
}),
|
||||
documentId: document.id,
|
||||
event: "documents.update",
|
||||
event: SubscriptionType.Document,
|
||||
resubscribe: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ async function teamProvisioner({
|
||||
paranoid: false,
|
||||
},
|
||||
],
|
||||
order: [["enabled", "DESC"]],
|
||||
});
|
||||
|
||||
// This authentication provider already exists which means we have a team and
|
||||
|
||||
@@ -63,7 +63,7 @@ export default async function userProvisioner({
|
||||
const auth = authentication
|
||||
? await UserAuthentication.findOne({
|
||||
where: {
|
||||
providerId: "" + authentication.providerId,
|
||||
providerId: String(authentication.providerId),
|
||||
},
|
||||
include: [
|
||||
{
|
||||
|
||||
@@ -11,6 +11,8 @@ import Heading from "./components/Heading";
|
||||
|
||||
type Props = EmailProps & {
|
||||
deleteConfirmationCode: string;
|
||||
teamName: string;
|
||||
teamUrl: string;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -29,15 +31,15 @@ export default class ConfirmUserDeleteEmail extends BaseEmail<Props> {
|
||||
return `Your requested account deletion code`;
|
||||
}
|
||||
|
||||
protected renderAsText({ deleteConfirmationCode }: Props): string {
|
||||
protected renderAsText({ teamName, deleteConfirmationCode }: Props): string {
|
||||
return `
|
||||
You requested to permanently delete your ${env.APP_NAME} account. Please enter the code below to confirm your account deletion.
|
||||
You requested to permanently delete your ${env.APP_NAME} user account in the ${teamName} workspace. Please enter the code below to confirm your account deletion.
|
||||
|
||||
Code: ${deleteConfirmationCode}
|
||||
`;
|
||||
}
|
||||
|
||||
protected render({ deleteConfirmationCode }: Props) {
|
||||
protected render({ teamUrl, teamName, deleteConfirmationCode }: Props) {
|
||||
return (
|
||||
<EmailTemplate previewText={this.preview()}>
|
||||
<Header />
|
||||
@@ -45,8 +47,9 @@ Code: ${deleteConfirmationCode}
|
||||
<Body>
|
||||
<Heading>Your account deletion request</Heading>
|
||||
<p>
|
||||
You requested to permanently delete your {env.APP_NAME} account.
|
||||
Please enter the code below to confirm your account deletion.
|
||||
You requested to permanently delete your {env.APP_NAME} user account
|
||||
in the <a href={teamUrl}>{teamName}</a> workspace. Please enter the
|
||||
code below to confirm your account deletion.
|
||||
</p>
|
||||
<EmptySpace height={5} />
|
||||
<p>
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"use strict";
|
||||
|
||||
/** @type {import('sequelize-cli').Migration} */
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
await queryInterface.sequelize.transaction(async transaction => {
|
||||
await queryInterface.addColumn(
|
||||
"subscriptions",
|
||||
"collectionId",
|
||||
{
|
||||
type: Sequelize.UUID,
|
||||
allowNull: true,
|
||||
onDelete: "cascade",
|
||||
references: {
|
||||
model: "collections",
|
||||
},
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
await queryInterface.addIndex(
|
||||
"subscriptions",
|
||||
["userId", "collectionId", "event"],
|
||||
{
|
||||
name: "subscriptions_user_id_collection_id_event",
|
||||
type: "UNIQUE",
|
||||
transaction,
|
||||
}
|
||||
);
|
||||
});
|
||||
},
|
||||
|
||||
async down(queryInterface, Sequelize) {
|
||||
await queryInterface.sequelize.transaction(async transaction => {
|
||||
await queryInterface.removeIndex(
|
||||
"subscriptions",
|
||||
["userId", "collectionId", "event"],
|
||||
{ transaction }
|
||||
);
|
||||
await queryInterface.removeColumn("subscriptions", "collectionId", {
|
||||
transaction,
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
'use strict';
|
||||
|
||||
/** @type {import('sequelize-cli').Migration} */
|
||||
module.exports = {
|
||||
async up (queryInterface) {
|
||||
await queryInterface.sequelize.query(
|
||||
`CREATE EXTENSION IF NOT EXISTS "pg_trgm";`,
|
||||
);
|
||||
await queryInterface.sequelize.query(
|
||||
`CREATE INDEX CONCURRENTLY documents_title_idx ON documents USING GIN (title gin_trgm_ops);`,
|
||||
);
|
||||
},
|
||||
|
||||
async down (queryInterface) {
|
||||
await queryInterface.sequelize.query(
|
||||
`DROP INDEX CONCURRENTLY documents_title_idx;`,
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
"use strict";
|
||||
|
||||
/** @type {import('sequelize-cli').Migration} */
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
await queryInterface.sequelize.transaction(async transaction => {
|
||||
await queryInterface.addColumn("teams", "previousSubdomains", {
|
||||
type: Sequelize.ARRAY(Sequelize.STRING),
|
||||
allowNull: true,
|
||||
}, { transaction });
|
||||
await queryInterface.sequelize.query(
|
||||
`CREATE INDEX teams_previous_subdomains ON teams USING GIN ("previousSubdomains");`,
|
||||
{ transaction }
|
||||
);
|
||||
});
|
||||
},
|
||||
|
||||
async down(queryInterface) {
|
||||
await queryInterface.sequelize.transaction(async transaction => {
|
||||
await queryInterface.sequelize.query(
|
||||
`DROP INDEX teams_previous_subdomains;`,
|
||||
{ transaction }
|
||||
);
|
||||
await queryInterface.removeColumn("teams", "previousSubdomains", { transaction });
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
const tableName = "team_domains";
|
||||
// because of this issue in Sequelize the foreign key constraint may be named differently depending
|
||||
// on when the previous migrations were ran https://github.com/sequelize/sequelize/pull/9890
|
||||
|
||||
const constraintNames = [
|
||||
"team_domains_createdById_fkey",
|
||||
"createdById_foreign_idx"
|
||||
];
|
||||
|
||||
module.exports = {
|
||||
up: async (queryInterface, Sequelize) => {
|
||||
let error;
|
||||
|
||||
for (const constraintName of constraintNames) {
|
||||
try {
|
||||
await queryInterface.sequelize.query(
|
||||
`alter table "${tableName}" drop constraint "${constraintName}"`
|
||||
);
|
||||
await queryInterface.sequelize.query(`alter table "${tableName}"
|
||||
add constraint "${constraintName}" foreign key("createdById") references "users" ("id")
|
||||
on delete set null`);
|
||||
return;
|
||||
} catch (err) {
|
||||
error = err;
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
},
|
||||
down: async (queryInterface, Sequelize) => {
|
||||
let error;
|
||||
|
||||
for (const constraintName of constraintNames) {
|
||||
try {
|
||||
await queryInterface.sequelize.query(
|
||||
`alter table "${tableName}" drop constraint "${constraintName}"`
|
||||
);
|
||||
await queryInterface.sequelize.query(`alter table "${tableName}"\
|
||||
add constraint "${constraintName}" foreign key("createdById") references "users" ("id")
|
||||
on delete no action`);
|
||||
return;
|
||||
} catch (err) {
|
||||
error = err;
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
},
|
||||
};
|
||||
@@ -46,6 +46,15 @@ describe("#ApiKey", () => {
|
||||
});
|
||||
|
||||
describe("canAccess", () => {
|
||||
it("should account for query string", async () => {
|
||||
const apiKey = await buildApiKey({
|
||||
name: "Dev",
|
||||
scope: ["/api/documents.info"],
|
||||
});
|
||||
|
||||
expect(apiKey.canAccess("/api/documents.info?foo=bar")).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true for all resources if no scope", async () => {
|
||||
const apiKey = await buildApiKey({
|
||||
name: "Dev",
|
||||
|
||||
@@ -174,6 +174,9 @@ class ApiKey extends ParanoidModel<
|
||||
return true;
|
||||
}
|
||||
|
||||
// strip any query string from the path
|
||||
path = path.split("?")[0];
|
||||
|
||||
const resource = path.split("/").pop() ?? "";
|
||||
const [namespace, method] = resource.split(".");
|
||||
|
||||
|
||||
@@ -981,7 +981,13 @@ class Document extends ArchivableModel<
|
||||
return false;
|
||||
};
|
||||
|
||||
unpublish = async (user: User) => {
|
||||
/**
|
||||
*
|
||||
* @param user User who is performing the action
|
||||
* @param options.detach Whether to detach the document from the containing collection
|
||||
* @returns Updated document
|
||||
*/
|
||||
unpublish = async (user: User, options: { detach: boolean }) => {
|
||||
// If the document is already a draft then calling unpublish should act like save
|
||||
if (!this.publishedAt) {
|
||||
return this.save();
|
||||
@@ -1010,6 +1016,11 @@ class Document extends ArchivableModel<
|
||||
this.createdBy = user;
|
||||
this.updatedBy = user;
|
||||
this.publishedAt = null;
|
||||
|
||||
if (options.detach) {
|
||||
this.collectionId = null;
|
||||
}
|
||||
|
||||
return this.save();
|
||||
};
|
||||
|
||||
|
||||
@@ -19,13 +19,17 @@ import {
|
||||
AfterCreate,
|
||||
AfterUpdate,
|
||||
AfterDestroy,
|
||||
BeforeDestroy,
|
||||
BeforeUpdate,
|
||||
} from "sequelize-typescript";
|
||||
import { CollectionPermission, DocumentPermission } from "@shared/types";
|
||||
import { ValidationError } from "@server/errors";
|
||||
import { APIContext } from "@server/types";
|
||||
import Collection from "./Collection";
|
||||
import Document from "./Document";
|
||||
import Group from "./Group";
|
||||
import User from "./User";
|
||||
import UserMembership from "./UserMembership";
|
||||
import { type HookContext } from "./base/Model";
|
||||
import ParanoidModel from "./base/ParanoidModel";
|
||||
import Fix from "./decorators/Fix";
|
||||
@@ -247,6 +251,36 @@ class GroupMembership extends ParanoidModel<
|
||||
}
|
||||
}
|
||||
|
||||
@BeforeUpdate
|
||||
static async checkLastAdminBeforeUpdate(
|
||||
model: GroupMembership,
|
||||
ctx: APIContext["context"]
|
||||
) {
|
||||
if (
|
||||
model.permission === CollectionPermission.Admin ||
|
||||
model.previous("permission") !== CollectionPermission.Admin ||
|
||||
!model.collectionId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await this.validateLastAdminPermission(model, ctx);
|
||||
}
|
||||
|
||||
@BeforeDestroy
|
||||
static async checkLastAdminBeforeDestroy(
|
||||
model: GroupMembership,
|
||||
ctx: APIContext["context"]
|
||||
) {
|
||||
// Only check for last admin permission if this permission is admin
|
||||
if (
|
||||
model.permission !== CollectionPermission.Admin ||
|
||||
!model.collectionId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await this.validateLastAdminPermission(model, ctx);
|
||||
}
|
||||
|
||||
@AfterUpdate
|
||||
static async publishAddGroupEventAfterUpdate(
|
||||
model: GroupMembership,
|
||||
@@ -366,6 +400,34 @@ class GroupMembership extends ParanoidModel<
|
||||
await Document.insertEvent(name, this, hookContext);
|
||||
}
|
||||
}
|
||||
|
||||
private static async validateLastAdminPermission(
|
||||
model: GroupMembership,
|
||||
{ transaction }: APIContext["context"]
|
||||
) {
|
||||
const [userMemberships, groupMemberships] = await Promise.all([
|
||||
UserMembership.count({
|
||||
where: {
|
||||
collectionId: model.collectionId,
|
||||
permission: CollectionPermission.Admin,
|
||||
},
|
||||
transaction,
|
||||
}),
|
||||
this.count({
|
||||
where: {
|
||||
collectionId: model.collectionId,
|
||||
permission: CollectionPermission.Admin,
|
||||
},
|
||||
transaction,
|
||||
}),
|
||||
]);
|
||||
|
||||
if (userMemberships === 0 && groupMemberships === 1) {
|
||||
throw ValidationError(
|
||||
"At least one user or group must have manage permissions"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default GroupMembership;
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
IsIn,
|
||||
Scopes,
|
||||
} from "sequelize-typescript";
|
||||
import { SubscriptionType } from "@shared/types";
|
||||
import Collection from "./Collection";
|
||||
import Document from "./Document";
|
||||
import User from "./User";
|
||||
import ParanoidModel from "./base/ParanoidModel";
|
||||
@@ -42,7 +44,14 @@ class Subscription extends ParanoidModel<
|
||||
@Column(DataType.UUID)
|
||||
documentId: string | null;
|
||||
|
||||
@IsIn([["documents.update"]])
|
||||
@BelongsTo(() => Collection, "collectionId")
|
||||
collection: Collection | null;
|
||||
|
||||
@ForeignKey(() => Document)
|
||||
@Column(DataType.UUID)
|
||||
collectionId: string | null;
|
||||
|
||||
@IsIn([Object.values(SubscriptionType)])
|
||||
@Column(DataType.STRING)
|
||||
event: string;
|
||||
}
|
||||
|
||||
+37
-14
@@ -1,20 +1,43 @@
|
||||
import { buildTeam, buildCollection } from "@server/test/factories";
|
||||
|
||||
describe("collectionIds", () => {
|
||||
it("should return non-private collection ids", async () => {
|
||||
const team = await buildTeam();
|
||||
const collection = await buildCollection({
|
||||
teamId: team.id,
|
||||
describe("Team", () => {
|
||||
describe("collectionIds", () => {
|
||||
it("should return non-private collection ids", async () => {
|
||||
const team = await buildTeam();
|
||||
const collection = await buildCollection({
|
||||
teamId: team.id,
|
||||
});
|
||||
// build a collection in another team
|
||||
await buildCollection();
|
||||
// build a private collection
|
||||
await buildCollection({
|
||||
teamId: team.id,
|
||||
permission: null,
|
||||
});
|
||||
const response = await team.collectionIds();
|
||||
expect(response.length).toEqual(1);
|
||||
expect(response[0]).toEqual(collection.id);
|
||||
});
|
||||
// build a collection in another team
|
||||
await buildCollection();
|
||||
// build a private collection
|
||||
await buildCollection({
|
||||
teamId: team.id,
|
||||
permission: null,
|
||||
});
|
||||
|
||||
describe("previousSubdomains", () => {
|
||||
it("should list the previous subdomains", async () => {
|
||||
const team = await buildTeam({
|
||||
subdomain: "example",
|
||||
});
|
||||
const subdomain = "updated";
|
||||
|
||||
await team.update({ subdomain });
|
||||
expect(team.subdomain).toEqual(subdomain);
|
||||
expect(team.previousSubdomains?.length).toEqual(1);
|
||||
expect(team.previousSubdomains?.[0]).toEqual("example");
|
||||
|
||||
const subdomain2 = "another";
|
||||
await team.update({ subdomain: subdomain2 });
|
||||
expect(team.subdomain).toEqual(subdomain2);
|
||||
expect(team.previousSubdomains?.length).toEqual(2);
|
||||
expect(team.previousSubdomains?.[0]).toEqual("example");
|
||||
expect(team.previousSubdomains?.[1]).toEqual(subdomain);
|
||||
});
|
||||
const response = await team.collectionIds();
|
||||
expect(response.length).toEqual(1);
|
||||
expect(response[0]).toEqual(collection.id);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -171,6 +171,9 @@ class Team extends ParanoidModel<
|
||||
@Column
|
||||
lastActiveAt: Date | null;
|
||||
|
||||
@Column(DataType.ARRAY(DataType.STRING))
|
||||
previousSubdomains: string[] | null;
|
||||
|
||||
// getters
|
||||
|
||||
/**
|
||||
@@ -369,6 +372,25 @@ class Team extends ParanoidModel<
|
||||
return model;
|
||||
}
|
||||
|
||||
@BeforeUpdate
|
||||
static async savePreviousSubdomain(model: Team) {
|
||||
const previousSubdomain = model.previous("subdomain");
|
||||
if (previousSubdomain && previousSubdomain !== model.subdomain) {
|
||||
model.previousSubdomains = model.previousSubdomains || [];
|
||||
|
||||
if (!model.previousSubdomains.includes(previousSubdomain)) {
|
||||
// Add the previous subdomain to the list of previous subdomains
|
||||
// upto a maximum of 3 previous subdomains
|
||||
model.previousSubdomains.push(previousSubdomain);
|
||||
if (model.previousSubdomains.length > 3) {
|
||||
model.previousSubdomains.shift();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return model;
|
||||
}
|
||||
|
||||
@AfterUpdate
|
||||
static deletePreviousAvatar = async (model: Team) => {
|
||||
const previousAvatarUrl = model.previous("avatarUrl");
|
||||
@@ -393,6 +415,41 @@ class Team extends ParanoidModel<
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Find a team by its current or previous subdomain.
|
||||
*
|
||||
* @param subdomain - The subdomain to search for.
|
||||
* @returns The team with the given or previous subdomain, or null if not found.
|
||||
*/
|
||||
static async findBySubdomain(subdomain: string) {
|
||||
// Preference is always given to the team with the subdomain currently
|
||||
// otherwise we can try and find a team that previously used the subdomain.
|
||||
return (
|
||||
(await this.findOne({
|
||||
where: {
|
||||
subdomain,
|
||||
},
|
||||
})) || (await this.findByPreviousSubdomain(subdomain))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a team by its previous subdomain.
|
||||
*
|
||||
* @param previousSubdomain - The previous subdomain to search for.
|
||||
* @returns The team with the given previous subdomain, or null if not found.
|
||||
*/
|
||||
static async findByPreviousSubdomain(previousSubdomain: string) {
|
||||
return this.findOne({
|
||||
where: {
|
||||
previousSubdomains: {
|
||||
[Op.contains]: [previousSubdomain],
|
||||
},
|
||||
},
|
||||
order: [["updatedAt", "DESC"]],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default Team;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { buildAdmin, buildTeam } from "@server/test/factories";
|
||||
import TeamDomain from "./TeamDomain";
|
||||
|
||||
describe("team domain model", () => {
|
||||
describe("TeamDomain", () => {
|
||||
describe("create", () => {
|
||||
it("should allow creation of domains", async () => {
|
||||
const team = await buildTeam();
|
||||
|
||||
@@ -79,6 +79,7 @@ describe("user model", () => {
|
||||
expect(response.length).toEqual(1);
|
||||
expect(response[0]).toEqual(collection.id);
|
||||
});
|
||||
|
||||
it("should return read collections", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({
|
||||
@@ -92,6 +93,7 @@ describe("user model", () => {
|
||||
expect(response.length).toEqual(1);
|
||||
expect(response[0]).toEqual(collection.id);
|
||||
});
|
||||
|
||||
it("should not return private collections", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({
|
||||
@@ -104,6 +106,7 @@ describe("user model", () => {
|
||||
const response = await user.collectionIds();
|
||||
expect(response.length).toEqual(0);
|
||||
});
|
||||
|
||||
it("should not return private collection with membership", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({
|
||||
|
||||
@@ -614,6 +614,7 @@ class User extends ParanoidModel<
|
||||
where: { email: this.email },
|
||||
},
|
||||
],
|
||||
order: [["createdAt", "ASC"]],
|
||||
});
|
||||
|
||||
// hooks
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user