mirror of
https://github.com/outline/outline.git
synced 2026-06-14 03:45:00 +03:00
Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8262c91319 | |||
| 41347970f5 | |||
| f0a04639ad | |||
| 12e4436e3a | |||
| 78383a1fbf | |||
| 29482c8307 | |||
| 9ea218af9b | |||
| 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 |
@@ -1,184 +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:
|
||||
resource_class: xlarge
|
||||
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 build
|
||||
|
||||
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_PASSWORD }}
|
||||
|
||||
- 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 }}
|
||||
@@ -3,7 +3,7 @@ Business Source License 1.1
|
||||
Parameters
|
||||
|
||||
Licensor: General Outline, Inc.
|
||||
Licensed Work: Outline 0.82.0
|
||||
Licensed Work: Outline 0.82.1-3
|
||||
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
|
||||
@@ -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: 2029-02-15
|
||||
Change Date: 2029-02-22
|
||||
|
||||
Change License: Apache License, Version 2.0
|
||||
|
||||
|
||||
@@ -8,8 +8,10 @@ import {
|
||||
SearchIcon,
|
||||
ShapesIcon,
|
||||
StarredIcon,
|
||||
SubscribeIcon,
|
||||
TrashIcon,
|
||||
UnstarredIcon,
|
||||
UnsubscribeIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
@@ -205,6 +207,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",
|
||||
@@ -331,5 +393,7 @@ export const rootCollectionActions = [
|
||||
createCollection,
|
||||
starCollection,
|
||||
unstarCollection,
|
||||
subscribeCollection,
|
||||
unsubscribeCollection,
|
||||
deleteCollection,
|
||||
];
|
||||
|
||||
@@ -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 }) => {
|
||||
@@ -1179,6 +1195,7 @@ export const rootDocumentActions = [
|
||||
openDocument,
|
||||
archiveDocument,
|
||||
createDocument,
|
||||
createDraftDocument,
|
||||
createNestedDocument,
|
||||
createTemplateFromDocument,
|
||||
deleteDocument,
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
) {
|
||||
|
||||
@@ -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">) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.fetchOne({
|
||||
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")}…`,
|
||||
|
||||
@@ -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,7 +24,6 @@ function TableOfContentsMenu() {
|
||||
Infinity
|
||||
);
|
||||
|
||||
// @ts-expect-error check
|
||||
const items: MenuItem[] = React.useMemo(() => {
|
||||
const i = [
|
||||
{
|
||||
@@ -34,17 +34,20 @@ function TableOfContentsMenu() {
|
||||
...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 +74,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);
|
||||
|
||||
+13
-3
@@ -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";
|
||||
@@ -448,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 = () => {
|
||||
@@ -461,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(
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
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 +18,148 @@ 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 latestEvent = merged[0];
|
||||
|
||||
if (latestEvent && document) {
|
||||
const latestRevisionEvent = merged.find(
|
||||
(event) => event.name === "revisions.create"
|
||||
);
|
||||
|
||||
if (latestEvent.createdAt !== document.updatedAt) {
|
||||
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;
|
||||
}, [document, revisionEvents, nonRevisionEvents]);
|
||||
|
||||
const onCloseHistory = React.useCallback(() => {
|
||||
if (document) {
|
||||
history.push({
|
||||
pathname: documentPath(document),
|
||||
@@ -37,30 +168,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 +177,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>}
|
||||
/>
|
||||
|
||||
@@ -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,7 +818,7 @@ export default class DocumentsStore extends Store<Document> {
|
||||
subscribe = (document: Document) =>
|
||||
this.rootStore.subscriptions.create({
|
||||
documentId: document.id,
|
||||
event: "documents.update",
|
||||
event: SubscriptionType.Document,
|
||||
});
|
||||
|
||||
unsubscribe = (document: Document) => {
|
||||
|
||||
@@ -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,5 +1,6 @@
|
||||
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";
|
||||
@@ -14,8 +15,16 @@ export default class SubscriptionsStore extends Store<Subscription> {
|
||||
}
|
||||
|
||||
@action
|
||||
async fetchOne({ documentId, event }: { documentId: string; event: string }) {
|
||||
const subscription = this.getByDocumentId(documentId);
|
||||
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;
|
||||
@@ -24,10 +33,7 @@ export default class SubscriptionsStore extends Store<Subscription> {
|
||||
this.isFetching = true;
|
||||
|
||||
try {
|
||||
const res = await client.post(`/${this.apiEndpoint}.info`, {
|
||||
documentId,
|
||||
event,
|
||||
});
|
||||
const res = await client.post(`/${this.apiEndpoint}.info`, options);
|
||||
invariant(res?.data, "Data should be available");
|
||||
return this.add(res.data);
|
||||
} catch (err) {
|
||||
@@ -42,4 +48,7 @@ export default class SubscriptionsStore extends Store<Subscription> {
|
||||
|
||||
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 = {
|
||||
|
||||
+13
-13
@@ -48,17 +48,17 @@
|
||||
"> 0.25%, not dead"
|
||||
],
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.744.0",
|
||||
"@aws-sdk/lib-storage": "3.744.0",
|
||||
"@aws-sdk/s3-presigned-post": "3.744.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.744.0",
|
||||
"@aws-sdk/signature-v4-crt": "^3.744.0",
|
||||
"@babel/core": "^7.26.7",
|
||||
"@aws-sdk/client-s3": "3.749.0",
|
||||
"@aws-sdk/lib-storage": "3.749.0",
|
||||
"@aws-sdk/s3-presigned-post": "3.749.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.749.0",
|
||||
"@aws-sdk/signature-v4-crt": "^3.749.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.1",
|
||||
"form-data": "^4.0.2",
|
||||
"fractional-index": "^1.0.0",
|
||||
"framer-motion": "^4.1.17",
|
||||
"fs-extra": "^11.2.0",
|
||||
@@ -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",
|
||||
@@ -242,7 +242,7 @@
|
||||
"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",
|
||||
@@ -264,7 +264,7 @@
|
||||
"@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",
|
||||
@@ -334,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",
|
||||
@@ -370,5 +370,5 @@
|
||||
"qs": "6.9.7",
|
||||
"rollup": "^4.5.1"
|
||||
},
|
||||
"version": "0.82.0"
|
||||
"version": "0.82.1-3"
|
||||
}
|
||||
|
||||
@@ -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.
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -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();
|
||||
|
||||
@@ -19,11 +19,15 @@ import {
|
||||
AfterUpdate,
|
||||
Length,
|
||||
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 GroupMembership from "./GroupMembership";
|
||||
import User from "./User";
|
||||
import IdModel from "./base/IdModel";
|
||||
import { HookContext } from "./base/Model";
|
||||
@@ -249,6 +253,36 @@ class UserMembership extends IdModel<
|
||||
}
|
||||
}
|
||||
|
||||
@BeforeUpdate
|
||||
static async checkLastAdminBeforeUpdate(
|
||||
model: UserMembership,
|
||||
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: UserMembership,
|
||||
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 publishAddUserEventAfterUpdate(
|
||||
model: UserMembership,
|
||||
@@ -346,6 +380,34 @@ class UserMembership extends IdModel<
|
||||
await Document.insertEvent(name, this, hookContext);
|
||||
}
|
||||
}
|
||||
|
||||
private static async validateLastAdminPermission(
|
||||
model: UserMembership,
|
||||
{ transaction }: APIContext["context"]
|
||||
) {
|
||||
const [userMemberships, groupMemberships] = await Promise.all([
|
||||
this.count({
|
||||
where: {
|
||||
collectionId: model.collectionId,
|
||||
permission: CollectionPermission.Admin,
|
||||
},
|
||||
transaction,
|
||||
}),
|
||||
GroupMembership.count({
|
||||
where: {
|
||||
collectionId: model.collectionId,
|
||||
permission: CollectionPermission.Admin,
|
||||
},
|
||||
transaction,
|
||||
}),
|
||||
]);
|
||||
|
||||
if (userMemberships === 1 && groupMemberships === 0) {
|
||||
throw ValidationError(
|
||||
"At least one user or group must have manage permissions"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default UserMembership;
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { NotificationEventType } from "@shared/types";
|
||||
import {
|
||||
buildDocument,
|
||||
buildSubscription,
|
||||
buildUser,
|
||||
} from "@server/test/factories";
|
||||
import NotificationHelper from "./NotificationHelper";
|
||||
|
||||
describe("NotificationHelper", () => {
|
||||
describe("getDocumentNotificationRecipients", () => {
|
||||
it("should return all users who have notification enabled for the event", async () => {
|
||||
const documentAuthor = await buildUser();
|
||||
const document = await buildDocument({
|
||||
userId: documentAuthor.id,
|
||||
teamId: documentAuthor.teamId,
|
||||
});
|
||||
const notificationEnabledUser = await buildUser({
|
||||
teamId: document.teamId,
|
||||
notificationSettings: { [NotificationEventType.UpdateDocument]: true },
|
||||
});
|
||||
|
||||
const recipients =
|
||||
await NotificationHelper.getDocumentNotificationRecipients({
|
||||
document,
|
||||
notificationType: NotificationEventType.UpdateDocument,
|
||||
onlySubscribers: false,
|
||||
actorId: documentAuthor.id,
|
||||
});
|
||||
|
||||
expect(recipients.length).toEqual(1);
|
||||
expect(recipients[0].id).toEqual(notificationEnabledUser.id);
|
||||
});
|
||||
|
||||
it("should return users who have subscribed to the document", async () => {
|
||||
const documentAuthor = await buildUser();
|
||||
const document = await buildDocument({
|
||||
userId: documentAuthor.id,
|
||||
teamId: documentAuthor.teamId,
|
||||
});
|
||||
const subscribedUser = await buildUser({ teamId: document.teamId });
|
||||
await buildSubscription({
|
||||
userId: subscribedUser.id,
|
||||
documentId: document.id,
|
||||
});
|
||||
|
||||
const recipients =
|
||||
await NotificationHelper.getDocumentNotificationRecipients({
|
||||
document,
|
||||
notificationType: NotificationEventType.UpdateDocument,
|
||||
onlySubscribers: true,
|
||||
actorId: documentAuthor.id,
|
||||
});
|
||||
|
||||
expect(recipients.length).toEqual(1);
|
||||
expect(recipients[0].id).toEqual(subscribedUser.id);
|
||||
});
|
||||
|
||||
it("should return users who have subscribed to the collection", async () => {
|
||||
const documentAuthor = await buildUser();
|
||||
const document = await buildDocument({
|
||||
userId: documentAuthor.id,
|
||||
teamId: documentAuthor.teamId,
|
||||
});
|
||||
const subscribedUser = await buildUser({ teamId: document.teamId });
|
||||
await buildSubscription({
|
||||
userId: subscribedUser.id,
|
||||
collectionId: document.collectionId!,
|
||||
});
|
||||
|
||||
const recipients =
|
||||
await NotificationHelper.getDocumentNotificationRecipients({
|
||||
document,
|
||||
notificationType: NotificationEventType.UpdateDocument,
|
||||
onlySubscribers: true,
|
||||
actorId: documentAuthor.id,
|
||||
});
|
||||
|
||||
expect(recipients.length).toEqual(1);
|
||||
expect(recipients[0].id).toEqual(subscribedUser.id);
|
||||
});
|
||||
|
||||
it("should return users who have subscribed to either the document or the containing collection", async () => {
|
||||
const documentAuthor = await buildUser();
|
||||
const document = await buildDocument({
|
||||
userId: documentAuthor.id,
|
||||
teamId: documentAuthor.teamId,
|
||||
});
|
||||
const [documentSubscribedUser, collectionSubscribedUser] =
|
||||
await Promise.all([
|
||||
buildUser({
|
||||
teamId: document.teamId,
|
||||
}),
|
||||
buildUser({
|
||||
teamId: document.teamId,
|
||||
}),
|
||||
]);
|
||||
await Promise.all([
|
||||
buildSubscription({
|
||||
userId: documentSubscribedUser.id,
|
||||
documentId: document.id,
|
||||
}),
|
||||
buildSubscription({
|
||||
userId: collectionSubscribedUser.id,
|
||||
collectionId: document.collectionId!,
|
||||
}),
|
||||
]);
|
||||
|
||||
const recipients =
|
||||
await NotificationHelper.getDocumentNotificationRecipients({
|
||||
document,
|
||||
notificationType: NotificationEventType.UpdateDocument,
|
||||
onlySubscribers: true,
|
||||
actorId: documentAuthor.id,
|
||||
});
|
||||
|
||||
expect(recipients.length).toEqual(2);
|
||||
|
||||
const recipientIds = recipients.map((u) => u.id);
|
||||
expect(recipientIds).toContain(collectionSubscribedUser.id);
|
||||
expect(recipientIds).toContain(documentSubscribedUser.id);
|
||||
});
|
||||
|
||||
it("should not return suspended users", async () => {
|
||||
const documentAuthor = await buildUser();
|
||||
const document = await buildDocument({
|
||||
userId: documentAuthor.id,
|
||||
teamId: documentAuthor.teamId,
|
||||
});
|
||||
const notificationEnabledUser = await buildUser({
|
||||
teamId: document.teamId,
|
||||
notificationSettings: { [NotificationEventType.UpdateDocument]: true },
|
||||
});
|
||||
// suspended user
|
||||
await buildUser({
|
||||
suspendedAt: new Date(),
|
||||
teamId: document.teamId,
|
||||
notificationSettings: { [NotificationEventType.UpdateDocument]: true },
|
||||
});
|
||||
|
||||
const recipients =
|
||||
await NotificationHelper.getDocumentNotificationRecipients({
|
||||
document,
|
||||
notificationType: NotificationEventType.UpdateDocument,
|
||||
onlySubscribers: false,
|
||||
actorId: documentAuthor.id,
|
||||
});
|
||||
|
||||
expect(recipients.length).toEqual(1);
|
||||
expect(recipients[0].id).toEqual(notificationEnabledUser.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,10 @@
|
||||
import uniq from "lodash/uniq";
|
||||
import { Op } from "sequelize";
|
||||
import { NotificationEventType, MentionType } from "@shared/types";
|
||||
import {
|
||||
NotificationEventType,
|
||||
MentionType,
|
||||
SubscriptionType,
|
||||
} from "@shared/types";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import {
|
||||
User,
|
||||
@@ -56,12 +60,12 @@ export default class NotificationHelper {
|
||||
comment: Comment,
|
||||
actorId: string
|
||||
): Promise<User[]> => {
|
||||
let recipients = await this.getDocumentNotificationRecipients(
|
||||
let recipients = await this.getDocumentNotificationRecipients({
|
||||
document,
|
||||
NotificationEventType.UpdateDocument,
|
||||
notificationType: NotificationEventType.UpdateDocument,
|
||||
onlySubscribers: !comment.parentCommentId,
|
||||
actorId,
|
||||
!comment.parentCommentId
|
||||
);
|
||||
});
|
||||
|
||||
recipients = recipients.filter((recipient) =>
|
||||
recipient.subscribedToEventType(NotificationEventType.CreateComment)
|
||||
@@ -127,18 +131,22 @@ export default class NotificationHelper {
|
||||
* Get the recipients of a notification for a document event.
|
||||
*
|
||||
* @param document The document to get recipients for.
|
||||
* @param eventType The event name.
|
||||
* @param notificationType The notification type for which to find the recipients.
|
||||
* @param onlySubscribers Whether to consider only the users who have active subscription to the document.
|
||||
* @param actorId The id of the user that performed the action.
|
||||
* @param onlySubscribers Whether to only return recipients that are actively
|
||||
* subscribed to the document.
|
||||
* @returns A list of recipients
|
||||
*/
|
||||
public static getDocumentNotificationRecipients = async (
|
||||
document: Document,
|
||||
eventType: NotificationEventType,
|
||||
actorId: string,
|
||||
onlySubscribers: boolean
|
||||
): Promise<User[]> => {
|
||||
public static getDocumentNotificationRecipients = async ({
|
||||
document,
|
||||
notificationType,
|
||||
onlySubscribers,
|
||||
actorId,
|
||||
}: {
|
||||
document: Document;
|
||||
notificationType: NotificationEventType;
|
||||
onlySubscribers: boolean;
|
||||
actorId: string;
|
||||
}): Promise<User[]> => {
|
||||
// First find all the users that have notifications enabled for this event
|
||||
// type at all and aren't the one that performed the action.
|
||||
let recipients = await User.findAll({
|
||||
@@ -151,7 +159,7 @@ export default class NotificationHelper {
|
||||
});
|
||||
|
||||
recipients = recipients.filter((recipient) =>
|
||||
recipient.subscribedToEventType(eventType)
|
||||
recipient.subscribedToEventType(notificationType)
|
||||
);
|
||||
|
||||
// Filter further to only those that have a subscription to the document…
|
||||
@@ -160,8 +168,11 @@ export default class NotificationHelper {
|
||||
attributes: ["userId"],
|
||||
where: {
|
||||
userId: recipients.map((recipient) => recipient.id),
|
||||
documentId: document.id,
|
||||
event: eventType,
|
||||
event: SubscriptionType.Document,
|
||||
[Op.or]: [
|
||||
{ collectionId: document.collectionId },
|
||||
{ documentId: document.id },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ allow(User, "read", Collection, (user, collection) => {
|
||||
|
||||
allow(
|
||||
User,
|
||||
["readDocument", "star", "unstar"],
|
||||
["readDocument", "star", "unstar", "subscribe", "unsubscribe"],
|
||||
Collection,
|
||||
(user, collection) => {
|
||||
if (!collection || user.teamId !== collection.teamId) {
|
||||
|
||||
@@ -5,6 +5,7 @@ export default function presentSubscription(subscription: Subscription) {
|
||||
id: subscription.id,
|
||||
userId: subscription.userId,
|
||||
documentId: subscription.documentId,
|
||||
collectionId: subscription.collectionId,
|
||||
event: subscription.event,
|
||||
createdAt: subscription.createdAt,
|
||||
updatedAt: subscription.updatedAt,
|
||||
|
||||
@@ -42,7 +42,6 @@ export default class WebsocketsProcessor {
|
||||
switch (event.name) {
|
||||
case "documents.create":
|
||||
case "documents.publish":
|
||||
case "documents.unpublish":
|
||||
case "documents.restore": {
|
||||
const document = await Document.findByPk(event.documentId, {
|
||||
paranoid: false,
|
||||
@@ -73,6 +72,28 @@ export default class WebsocketsProcessor {
|
||||
});
|
||||
}
|
||||
|
||||
case "documents.unpublish": {
|
||||
const document = await Document.findByPk(event.documentId, {
|
||||
paranoid: false,
|
||||
});
|
||||
|
||||
if (!document) {
|
||||
return;
|
||||
}
|
||||
|
||||
const documentToPresent = await presentDocument(undefined, document);
|
||||
|
||||
const channels = await this.getDocumentEventChannels(event, document);
|
||||
|
||||
// We need to add the collection channel to let the members update the doc structure.
|
||||
channels.push(`collection-${event.collectionId}`);
|
||||
|
||||
return socketio.to(channels).emit(event.name, {
|
||||
document: documentToPresent,
|
||||
collectionId: event.collectionId,
|
||||
});
|
||||
}
|
||||
|
||||
case "documents.unarchive": {
|
||||
const [document, srcCollection] = await Promise.all([
|
||||
Document.findByPk(event.documentId, { paranoid: false }),
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { MentionType, NotificationEventType } from "@shared/types";
|
||||
import {
|
||||
MentionType,
|
||||
NotificationEventType,
|
||||
SubscriptionType,
|
||||
} from "@shared/types";
|
||||
import subscriptionCreator from "@server/commands/subscriptionCreator";
|
||||
import { createContext } from "@server/context";
|
||||
import { Comment, Document, Notification, User } from "@server/models";
|
||||
@@ -34,7 +38,7 @@ export default class CommentCreatedNotificationsTask extends BaseTask<CommentEve
|
||||
transaction,
|
||||
}),
|
||||
documentId: document.id,
|
||||
event: "documents.update",
|
||||
event: SubscriptionType.Document,
|
||||
resubscribe: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,12 +51,12 @@ export default class DocumentPublishedNotificationsTask extends BaseTask<Documen
|
||||
}
|
||||
|
||||
const recipients = (
|
||||
await NotificationHelper.getDocumentNotificationRecipients(
|
||||
await NotificationHelper.getDocumentNotificationRecipients({
|
||||
document,
|
||||
NotificationEventType.PublishDocument,
|
||||
document.lastModifiedById,
|
||||
false
|
||||
)
|
||||
notificationType: NotificationEventType.PublishDocument,
|
||||
onlySubscribers: false,
|
||||
actorId: document.lastModifiedById,
|
||||
})
|
||||
).filter((recipient) => !userIdsMentioned.includes(recipient.id));
|
||||
|
||||
for (const recipient of recipients) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Transaction } from "sequelize";
|
||||
import { SubscriptionType } from "@shared/types";
|
||||
import subscriptionCreator from "@server/commands/subscriptionCreator";
|
||||
import { createContext } from "@server/context";
|
||||
import { Subscription, User } from "@server/models";
|
||||
@@ -34,7 +35,7 @@ export default class DocumentSubscriptionTask extends BaseTask<DocumentUserEvent
|
||||
transaction,
|
||||
}),
|
||||
documentId: event.documentId,
|
||||
event: "documents.update",
|
||||
event: SubscriptionType.Document,
|
||||
resubscribe: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -176,7 +176,7 @@ export default class ImportMarkdownZipTask extends ImportTask {
|
||||
document.text = document.text
|
||||
.replace(new RegExp(escapeRegExp(encodedPath), "g"), reference)
|
||||
.replace(
|
||||
new RegExp(`\.?/?${escapeRegExp(normalizedAttachmentPath)}`, "g"),
|
||||
new RegExp(`\\\.?/?${escapeRegExp(normalizedAttachmentPath)}`, "g"),
|
||||
reference
|
||||
);
|
||||
}
|
||||
|
||||
@@ -73,12 +73,12 @@ export default class RevisionCreatedNotificationsTask extends BaseTask<RevisionE
|
||||
}
|
||||
|
||||
const recipients = (
|
||||
await NotificationHelper.getDocumentNotificationRecipients(
|
||||
await NotificationHelper.getDocumentNotificationRecipients({
|
||||
document,
|
||||
NotificationEventType.UpdateDocument,
|
||||
document.lastModifiedById,
|
||||
true
|
||||
)
|
||||
notificationType: NotificationEventType.UpdateDocument,
|
||||
onlySubscribers: true,
|
||||
actorId: document.lastModifiedById,
|
||||
})
|
||||
).filter((recipient) => !userIdsMentioned.includes(recipient.id));
|
||||
if (!recipients.length) {
|
||||
return;
|
||||
|
||||
@@ -176,6 +176,8 @@ router.post(
|
||||
throw InvalidRequestError(response.error);
|
||||
}
|
||||
|
||||
await attachment.reload();
|
||||
|
||||
ctx.body = {
|
||||
data: presentAttachment(attachment),
|
||||
};
|
||||
|
||||
@@ -1454,7 +1454,7 @@ router.post(
|
||||
auth(),
|
||||
validate(T.DocumentsUnpublishSchema),
|
||||
async (ctx: APIContext<T.DocumentsUnpublishReq>) => {
|
||||
const { id } = ctx.input.body;
|
||||
const { id, detach } = ctx.input.body;
|
||||
const { user } = ctx.state.auth;
|
||||
|
||||
const document = await Document.findByPk(id, {
|
||||
@@ -1473,14 +1473,14 @@ router.post(
|
||||
);
|
||||
}
|
||||
|
||||
await document.unpublish(user);
|
||||
// detaching would unset collectionId from document, so save a ref to the affected collectionId.
|
||||
const collectionId = document.collectionId;
|
||||
|
||||
await document.unpublish(user, { detach });
|
||||
await Event.createFromContext(ctx, {
|
||||
name: "documents.unpublish",
|
||||
documentId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
data: {
|
||||
title: document.title,
|
||||
},
|
||||
collectionId,
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
|
||||
@@ -300,6 +300,9 @@ export type DocumentsDeleteReq = z.infer<typeof DocumentsDeleteSchema>;
|
||||
|
||||
export const DocumentsUnpublishSchema = BaseSchema.extend({
|
||||
body: BaseIdSchema.extend({
|
||||
/** Whether to detach the document from the collection */
|
||||
detach: z.boolean().default(false),
|
||||
|
||||
/** @deprecated Version of the API to be used, remove in a few releases */
|
||||
apiVersion: z.number().optional(),
|
||||
}),
|
||||
|
||||
@@ -228,6 +228,45 @@ describe("#events.list", () => {
|
||||
expect(body.data[0].id).toEqual(event.id);
|
||||
});
|
||||
|
||||
it("should allow filtering by events param", async () => {
|
||||
const user = await buildUser();
|
||||
const admin = await buildAdmin({ teamId: user.teamId });
|
||||
const collection = await buildCollection({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
const document = await buildDocument({
|
||||
userId: user.id,
|
||||
collectionId: collection.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
// audit event
|
||||
await buildEvent({
|
||||
name: "users.promote",
|
||||
teamId: user.teamId,
|
||||
actorId: admin.id,
|
||||
userId: user.id,
|
||||
});
|
||||
// event viewable in activity stream
|
||||
const event = await buildEvent({
|
||||
name: "documents.publish",
|
||||
collectionId: collection.id,
|
||||
documentId: document.id,
|
||||
teamId: user.teamId,
|
||||
actorId: user.id,
|
||||
});
|
||||
const res = await server.post("/api/events.list", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
events: ["documents.publish"],
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.length).toEqual(1);
|
||||
expect(body.data[0].id).toEqual(event.id);
|
||||
});
|
||||
|
||||
it("should return events with deleted actors", async () => {
|
||||
const user = await buildUser();
|
||||
const admin = await buildAdmin({ teamId: user.teamId });
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Router from "koa-router";
|
||||
import intersection from "lodash/intersection";
|
||||
import { Op, WhereOptions } from "sequelize";
|
||||
import { EventHelper } from "@shared/utils/EventHelper";
|
||||
import auth from "@server/middlewares/authentication";
|
||||
@@ -20,20 +21,35 @@ router.post(
|
||||
async (ctx: APIContext<T.EventsListReq>) => {
|
||||
const { user } = ctx.state.auth;
|
||||
const {
|
||||
sort,
|
||||
direction,
|
||||
name,
|
||||
events,
|
||||
auditLog,
|
||||
actorId,
|
||||
documentId,
|
||||
collectionId,
|
||||
name,
|
||||
auditLog,
|
||||
sort,
|
||||
direction,
|
||||
} = ctx.input.body;
|
||||
|
||||
let where: WhereOptions<Event> = {
|
||||
name: EventHelper.ACTIVITY_EVENTS,
|
||||
teamId: user.teamId,
|
||||
};
|
||||
|
||||
if (auditLog) {
|
||||
authorize(user, "audit", user.team);
|
||||
where.name = events
|
||||
? intersection(EventHelper.AUDIT_EVENTS, events)
|
||||
: EventHelper.AUDIT_EVENTS;
|
||||
} else {
|
||||
where.name = events
|
||||
? intersection(EventHelper.ACTIVITY_EVENTS, events)
|
||||
: EventHelper.ACTIVITY_EVENTS;
|
||||
}
|
||||
|
||||
if (name && (where.name as string[]).includes(name)) {
|
||||
where.name = name;
|
||||
}
|
||||
|
||||
if (actorId) {
|
||||
where = { ...where, actorId };
|
||||
}
|
||||
@@ -42,15 +58,6 @@ router.post(
|
||||
where = { ...where, documentId };
|
||||
}
|
||||
|
||||
if (auditLog) {
|
||||
authorize(user, "audit", user.team);
|
||||
where.name = EventHelper.AUDIT_EVENTS;
|
||||
}
|
||||
|
||||
if (name && (where.name as string[]).includes(name)) {
|
||||
where.name = name;
|
||||
}
|
||||
|
||||
if (collectionId) {
|
||||
where = { ...where, collectionId };
|
||||
|
||||
@@ -77,7 +84,7 @@ router.post(
|
||||
};
|
||||
}
|
||||
|
||||
const events = await Event.findAll({
|
||||
const loadedEvents = await Event.findAll({
|
||||
where,
|
||||
order: [[sort, direction]],
|
||||
include: [
|
||||
@@ -94,7 +101,7 @@ router.post(
|
||||
ctx.body = {
|
||||
pagination: ctx.state.pagination,
|
||||
data: await Promise.all(
|
||||
events.map((event) => presentEvent(event, auditLog))
|
||||
loadedEvents.map((event) => presentEvent(event, auditLog))
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
import { z } from "zod";
|
||||
import { EventHelper } from "@shared/utils/EventHelper";
|
||||
import { BaseSchema } from "@server/routes/api/schema";
|
||||
|
||||
export const EventsListSchema = BaseSchema.extend({
|
||||
body: z.object({
|
||||
/** Events to retrieve */
|
||||
events: z
|
||||
.array(
|
||||
z.union([
|
||||
z.enum(EventHelper.ACTIVITY_EVENTS),
|
||||
z.enum(EventHelper.AUDIT_EVENTS),
|
||||
])
|
||||
)
|
||||
.optional(),
|
||||
|
||||
/** Id of the user who performed the action */
|
||||
actorId: z.string().uuid().optional(),
|
||||
|
||||
@@ -15,7 +26,9 @@ export const EventsListSchema = BaseSchema.extend({
|
||||
/** Whether to include audit events */
|
||||
auditLog: z.boolean().default(false),
|
||||
|
||||
/** Name of the event to retrieve */
|
||||
/** @deprecated, use 'events' parameter instead
|
||||
* Name of the event to retrieve
|
||||
*/
|
||||
name: z.string().optional(),
|
||||
|
||||
/** The attribute to sort the events by */
|
||||
|
||||
@@ -168,6 +168,84 @@ describe("#pins.create", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("#pins.info", () => {
|
||||
it("should provide info about a home pin", async () => {
|
||||
const admin = await buildAdmin();
|
||||
const document = await buildDocument({
|
||||
userId: admin.id,
|
||||
teamId: admin.teamId,
|
||||
});
|
||||
|
||||
await server.post("/api/pins.create", {
|
||||
body: {
|
||||
token: admin.getJwtToken(),
|
||||
documentId: document.id,
|
||||
},
|
||||
});
|
||||
|
||||
const res = await server.post("/api/pins.info", {
|
||||
body: {
|
||||
token: admin.getJwtToken(),
|
||||
documentId: document.id,
|
||||
},
|
||||
});
|
||||
const pin = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(pin.data.id).toBeDefined();
|
||||
expect(pin.data.documentId).toEqual(document.id);
|
||||
expect(pin.data.collectionId).toBeFalsy();
|
||||
});
|
||||
|
||||
it("should provide info about a collection pin", async () => {
|
||||
const user = await buildUser();
|
||||
const document = await buildDocument({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
|
||||
await server.post("/api/pins.create", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
documentId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
},
|
||||
});
|
||||
|
||||
const res = await server.post("/api/pins.info", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
documentId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
},
|
||||
});
|
||||
const pin = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(pin.data.id).toBeDefined();
|
||||
expect(pin.data.documentId).toEqual(document.id);
|
||||
expect(pin.data.collectionId).toEqual(document.collectionId);
|
||||
});
|
||||
|
||||
it("should throw 404 if no pin found", async () => {
|
||||
const user = await buildUser();
|
||||
const document = await buildDocument({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
|
||||
const res = await server.post("/api/pins.info", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
documentId: document.id,
|
||||
collectionId: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toEqual(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#pins.list", () => {
|
||||
let user: User;
|
||||
let pins: Pin[];
|
||||
|
||||
@@ -57,12 +57,41 @@ router.post(
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
"pins.info",
|
||||
auth(),
|
||||
validate(T.PinsInfoSchema),
|
||||
async (ctx: APIContext<T.PinsInfoReq>) => {
|
||||
const { user } = ctx.state.auth;
|
||||
const { documentId, collectionId } = ctx.input.body;
|
||||
|
||||
const document = await Document.findByPk(documentId, { userId: user.id });
|
||||
authorize(user, "read", document);
|
||||
|
||||
// There can be only one pin with these props.
|
||||
const pin = await Pin.findOne({
|
||||
where: {
|
||||
documentId,
|
||||
collectionId: collectionId ?? null,
|
||||
createdById: user.id,
|
||||
teamId: user.teamId,
|
||||
},
|
||||
rejectOnEmpty: true,
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
data: presentPin(pin),
|
||||
policies: presentPolicies(user, [pin]),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
"pins.list",
|
||||
auth(),
|
||||
validate(T.PinsListSchema),
|
||||
pagination(),
|
||||
async (ctx: APIContext<T.PinsCreateReq>) => {
|
||||
async (ctx: APIContext<T.PinsListReq>) => {
|
||||
const { collectionId } = ctx.input.body;
|
||||
const { user } = ctx.state.auth;
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import isUUID from "validator/lib/isUUID";
|
||||
import { z } from "zod";
|
||||
import { UrlHelper } from "@shared/utils/UrlHelper";
|
||||
import { zodIdType } from "@server/utils/zod";
|
||||
import { BaseSchema } from "../schema";
|
||||
|
||||
export const PinsCreateSchema = BaseSchema.extend({
|
||||
@@ -24,13 +25,24 @@ export const PinsCreateSchema = BaseSchema.extend({
|
||||
|
||||
export type PinsCreateReq = z.infer<typeof PinsCreateSchema>;
|
||||
|
||||
export const PinsInfoSchema = BaseSchema.extend({
|
||||
body: z.object({
|
||||
/** Document to get the pin info for. */
|
||||
documentId: zodIdType(),
|
||||
/** Collection to which the pin belongs to. If not set, it's considered as "Home" pin. */
|
||||
collectionId: z.string().uuid().nullish(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type PinsInfoReq = z.infer<typeof PinsInfoSchema>;
|
||||
|
||||
export const PinsListSchema = BaseSchema.extend({
|
||||
body: z.object({
|
||||
collectionId: z.string().uuid().nullish(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type PinsListReq = z.infer<typeof PinsCreateSchema>;
|
||||
export type PinsListReq = z.infer<typeof PinsListSchema>;
|
||||
|
||||
export const PinsUpdateSchema = BaseSchema.extend({
|
||||
body: z.object({
|
||||
|
||||
@@ -125,6 +125,7 @@ router.post(
|
||||
|
||||
const document = await Document.findByPk(documentId, {
|
||||
userId: user.id,
|
||||
paranoid: false,
|
||||
});
|
||||
authorize(user, "listRevisions", document);
|
||||
|
||||
|
||||
@@ -1,36 +1,38 @@
|
||||
import isEmpty from "lodash/isEmpty";
|
||||
import { z } from "zod";
|
||||
import { SubscriptionType } from "@shared/types";
|
||||
import { ValidateDocumentId } from "@server/validation";
|
||||
import { BaseSchema } from "../schema";
|
||||
|
||||
const SubscriptionBody = z
|
||||
.object({
|
||||
event: z.literal(SubscriptionType.Document),
|
||||
collectionId: z.string().uuid().optional(),
|
||||
documentId: z
|
||||
.string()
|
||||
.refine(ValidateDocumentId.isValid, {
|
||||
message: ValidateDocumentId.message,
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.refine((obj) => !(isEmpty(obj.collectionId) && isEmpty(obj.documentId)), {
|
||||
message: "one of collectionId or documentId is required",
|
||||
});
|
||||
|
||||
export const SubscriptionsListSchema = BaseSchema.extend({
|
||||
body: z.object({
|
||||
documentId: z.string().refine(ValidateDocumentId.isValid, {
|
||||
message: ValidateDocumentId.message,
|
||||
}),
|
||||
event: z.literal("documents.update"),
|
||||
}),
|
||||
body: SubscriptionBody,
|
||||
});
|
||||
|
||||
export type SubscriptionsListReq = z.infer<typeof SubscriptionsListSchema>;
|
||||
|
||||
export const SubscriptionsInfoSchema = BaseSchema.extend({
|
||||
body: z.object({
|
||||
documentId: z.string().refine(ValidateDocumentId.isValid, {
|
||||
message: ValidateDocumentId.message,
|
||||
}),
|
||||
event: z.literal("documents.update"),
|
||||
}),
|
||||
body: SubscriptionBody,
|
||||
});
|
||||
|
||||
export type SubscriptionsInfoReq = z.infer<typeof SubscriptionsInfoSchema>;
|
||||
|
||||
export const SubscriptionsCreateSchema = BaseSchema.extend({
|
||||
body: z.object({
|
||||
documentId: z.string().refine(ValidateDocumentId.isValid, {
|
||||
message: ValidateDocumentId.message,
|
||||
}),
|
||||
event: z.literal("documents.update"),
|
||||
}),
|
||||
body: SubscriptionBody,
|
||||
});
|
||||
|
||||
export type SubscriptionsCreateReq = z.infer<typeof SubscriptionsCreateSchema>;
|
||||
|
||||
@@ -1,15 +1,41 @@
|
||||
import { SubscriptionType } from "@shared/types";
|
||||
import { Event } from "@server/models";
|
||||
import {
|
||||
buildUser,
|
||||
buildSubscription,
|
||||
buildDocument,
|
||||
buildCollection,
|
||||
} from "@server/test/factories";
|
||||
import { getTestServer } from "@server/test/support";
|
||||
|
||||
const server = getTestServer();
|
||||
|
||||
describe("#subscriptions.create", () => {
|
||||
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 res = await server.post("/api/subscriptions.create", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
collectionId: collection.id,
|
||||
event: SubscriptionType.Document,
|
||||
},
|
||||
});
|
||||
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.id).toBeDefined();
|
||||
expect(body.data.userId).toEqual(user.id);
|
||||
expect(body.data.collectionId).toEqual(collection.id);
|
||||
});
|
||||
|
||||
it("should create a document subscription", async () => {
|
||||
const user = await buildUser();
|
||||
|
||||
const document = await buildDocument({
|
||||
@@ -21,7 +47,7 @@ describe("#subscriptions.create", () => {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
documentId: document.id,
|
||||
event: "documents.update",
|
||||
event: SubscriptionType.Document,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -45,7 +71,7 @@ describe("#subscriptions.create", () => {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
documentId: document.id,
|
||||
event: "documents.update",
|
||||
event: SubscriptionType.Document,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -79,7 +105,7 @@ describe("#subscriptions.create", () => {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
documentId: document.id,
|
||||
event: "documents.update",
|
||||
event: SubscriptionType.Document,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -88,7 +114,7 @@ describe("#subscriptions.create", () => {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
documentId: document.id,
|
||||
event: "documents.update",
|
||||
event: SubscriptionType.Document,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -97,17 +123,16 @@ describe("#subscriptions.create", () => {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
documentId: document.id,
|
||||
event: "documents.update",
|
||||
event: SubscriptionType.Document,
|
||||
},
|
||||
});
|
||||
|
||||
// List subscriptions associated with
|
||||
// `document.id`
|
||||
// List subscriptions associated with `document.id`
|
||||
const res = await server.post("/api/subscriptions.list", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
documentId: document.id,
|
||||
event: "documents.update",
|
||||
event: SubscriptionType.Document,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -132,8 +157,7 @@ describe("#subscriptions.create", () => {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
documentId: document.id,
|
||||
// Subscription on event
|
||||
// that cannot be subscribed to.
|
||||
// Subscription on event that cannot be subscribed to.
|
||||
event: "documents.publish",
|
||||
},
|
||||
});
|
||||
@@ -147,10 +171,62 @@ describe("#subscriptions.create", () => {
|
||||
`event: Invalid literal value, expected "documents.update"`
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw 400 when neither documentId nor collectionId is provided", async () => {
|
||||
const user = await buildUser();
|
||||
|
||||
const res = await server.post("/api/subscriptions.create", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
event: SubscriptionType.Document,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(400);
|
||||
expect(body.ok).toEqual(false);
|
||||
expect(body.error).toEqual("validation_error");
|
||||
expect(body.message).toEqual(
|
||||
"body: one of collectionId or documentId is required"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#subscriptions.info", () => {
|
||||
it("should provide info about a subscription", async () => {
|
||||
it("should provide info about a document subscription for the collection", async () => {
|
||||
const user = await buildUser();
|
||||
|
||||
const subscriber = await buildUser({ teamId: user.teamId });
|
||||
|
||||
const collection = await buildCollection({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
|
||||
await server.post("/api/subscriptions.create", {
|
||||
body: {
|
||||
token: subscriber.getJwtToken(),
|
||||
collectionId: collection.id,
|
||||
event: SubscriptionType.Document,
|
||||
},
|
||||
});
|
||||
|
||||
const res = await server.post("/api/subscriptions.info", {
|
||||
body: {
|
||||
token: subscriber.getJwtToken(),
|
||||
collectionId: collection.id,
|
||||
event: SubscriptionType.Document,
|
||||
},
|
||||
});
|
||||
const subscription = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(subscription.data.id).toBeDefined();
|
||||
expect(subscription.data.userId).toEqual(subscriber.id);
|
||||
expect(subscription.data.collectionId).toEqual(collection.id);
|
||||
});
|
||||
|
||||
it("should provide info about a document subscription", async () => {
|
||||
const creator = await buildUser();
|
||||
|
||||
const subscriber = await buildUser({ teamId: creator.teamId });
|
||||
@@ -171,7 +247,7 @@ describe("#subscriptions.info", () => {
|
||||
body: {
|
||||
token: subscriber.getJwtToken(),
|
||||
documentId: document0.id,
|
||||
event: "documents.update",
|
||||
event: SubscriptionType.Document,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -180,7 +256,7 @@ describe("#subscriptions.info", () => {
|
||||
body: {
|
||||
token: subscriber.getJwtToken(),
|
||||
documentId: document1.id,
|
||||
event: "documents.update",
|
||||
event: SubscriptionType.Document,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -190,7 +266,7 @@ describe("#subscriptions.info", () => {
|
||||
body: {
|
||||
token: subscriber.getJwtToken(),
|
||||
documentId: document0.id,
|
||||
event: "documents.update",
|
||||
event: SubscriptionType.Document,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -202,6 +278,25 @@ describe("#subscriptions.info", () => {
|
||||
expect(response0.data.documentId).toEqual(document0.id);
|
||||
});
|
||||
|
||||
it("should throw 400 when neither documentId nor collectionId is provided", async () => {
|
||||
const user = await buildUser();
|
||||
|
||||
const res = await server.post("/api/subscriptions.info", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
event: SubscriptionType.Document,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(400);
|
||||
expect(body.ok).toEqual(false);
|
||||
expect(body.error).toEqual("validation_error");
|
||||
expect(body.message).toEqual(
|
||||
"body: one of collectionId or documentId is required"
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw 404 if no subscription found", async () => {
|
||||
const author = await buildUser();
|
||||
const subscriber = await buildUser({ teamId: author.teamId });
|
||||
@@ -214,7 +309,7 @@ describe("#subscriptions.info", () => {
|
||||
body: {
|
||||
token: subscriber.getJwtToken(),
|
||||
documentId: document.id,
|
||||
event: "documents.update",
|
||||
event: SubscriptionType.Document,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -243,7 +338,7 @@ describe("#subscriptions.info", () => {
|
||||
body: {
|
||||
token: subscriber.getJwtToken(),
|
||||
documentId: document0.id,
|
||||
event: "documents.update",
|
||||
event: SubscriptionType.Document,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -252,17 +347,16 @@ describe("#subscriptions.info", () => {
|
||||
body: {
|
||||
token: subscriber.getJwtToken(),
|
||||
documentId: document1.id,
|
||||
event: "documents.update",
|
||||
event: SubscriptionType.Document,
|
||||
},
|
||||
});
|
||||
|
||||
// `viewer` wants info about `subscriber`'s
|
||||
// subscription on `document0`.
|
||||
// `viewer` wants info about `subscriber`'s subscription on `document0`.
|
||||
const subscription0 = await server.post("/api/subscriptions.info", {
|
||||
body: {
|
||||
token: viewer.getJwtToken(),
|
||||
documentId: document0.id,
|
||||
event: "documents.update",
|
||||
event: SubscriptionType.Document,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -274,13 +368,12 @@ describe("#subscriptions.info", () => {
|
||||
expect(response0.error).toEqual("authorization_error");
|
||||
expect(response0.message).toEqual("Authorization error");
|
||||
|
||||
// `viewer` wants info about `subscriber`'s
|
||||
// subscription on `document0`.
|
||||
// `viewer` wants info about `subscriber`'s subscription on `document0`.
|
||||
const subscription1 = await server.post("/api/subscriptions.info", {
|
||||
body: {
|
||||
token: viewer.getJwtToken(),
|
||||
documentId: document1.id,
|
||||
event: "documents.update",
|
||||
event: SubscriptionType.Document,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -316,7 +409,7 @@ describe("#subscriptions.info", () => {
|
||||
body: {
|
||||
token: subscriber.getJwtToken(),
|
||||
documentId: document0.id,
|
||||
event: "documents.update",
|
||||
event: SubscriptionType.Document,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -325,13 +418,11 @@ describe("#subscriptions.info", () => {
|
||||
body: {
|
||||
token: subscriber.getJwtToken(),
|
||||
documentId: document1.id,
|
||||
event: "documents.update",
|
||||
event: SubscriptionType.Document,
|
||||
},
|
||||
});
|
||||
|
||||
// `viewer` wants info about `subscriber`'s
|
||||
// subscription on `document0`.
|
||||
// They have requested an invalid event.
|
||||
// `viewer` wants info about `subscriber`'s subscription on `document0` - they have requested an invalid event.
|
||||
const subscription0 = await server.post("/api/subscriptions.info", {
|
||||
body: {
|
||||
token: viewer.getJwtToken(),
|
||||
@@ -372,7 +463,7 @@ describe("#subscriptions.info", () => {
|
||||
});
|
||||
|
||||
describe("#subscriptions.list", () => {
|
||||
it("should list user subscriptions", async () => {
|
||||
it("should list user subscriptions for the document", async () => {
|
||||
const user = await buildUser();
|
||||
|
||||
const document = await buildDocument({
|
||||
@@ -380,19 +471,16 @@ describe("#subscriptions.list", () => {
|
||||
teamId: user.teamId,
|
||||
});
|
||||
|
||||
await buildSubscription();
|
||||
|
||||
const subscription = await buildSubscription({
|
||||
userId: user.id,
|
||||
documentId: document.id,
|
||||
event: "documents.update",
|
||||
});
|
||||
|
||||
const res = await server.post("/api/subscriptions.list", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
documentId: document.id,
|
||||
event: "documents.update",
|
||||
event: SubscriptionType.Document,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -594,6 +682,25 @@ describe("#subscriptions.list", () => {
|
||||
expect(body.error).toEqual("authorization_error");
|
||||
expect(body.message).toEqual("Authorization error");
|
||||
});
|
||||
|
||||
it("should throw 400 when neither documentId nor collectionId is provided", async () => {
|
||||
const user = await buildUser();
|
||||
|
||||
const res = await server.post("/api/subscriptions.list", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
event: SubscriptionType.Document,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(400);
|
||||
expect(body.ok).toEqual(false);
|
||||
expect(body.error).toEqual("validation_error");
|
||||
expect(body.message).toEqual(
|
||||
"body: one of collectionId or documentId is required"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#subscriptions.delete", () => {
|
||||
@@ -608,7 +715,6 @@ describe("#subscriptions.delete", () => {
|
||||
const subscription = await buildSubscription({
|
||||
userId: user.id,
|
||||
documentId: document.id,
|
||||
event: "documents.update",
|
||||
});
|
||||
|
||||
const res = await server.post("/api/subscriptions.delete", {
|
||||
@@ -637,7 +743,6 @@ describe("#subscriptions.delete", () => {
|
||||
const subscription = await buildSubscription({
|
||||
userId: user.id,
|
||||
documentId: document.id,
|
||||
event: "documents.update",
|
||||
});
|
||||
|
||||
const res = await server.post("/api/subscriptions.delete", {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Router from "koa-router";
|
||||
import { Transaction } from "sequelize";
|
||||
import { Transaction, WhereOptions } from "sequelize";
|
||||
import { QueryNotices } from "@shared/types";
|
||||
import subscriptionCreator from "@server/commands/subscriptionCreator";
|
||||
import { createContext } from "@server/context";
|
||||
@@ -8,7 +8,7 @@ import auth from "@server/middlewares/authentication";
|
||||
import { rateLimiter } from "@server/middlewares/rateLimiter";
|
||||
import { transaction } from "@server/middlewares/transaction";
|
||||
import validate from "@server/middlewares/validate";
|
||||
import { Subscription, Document, User } from "@server/models";
|
||||
import { Subscription, Document, User, Collection } from "@server/models";
|
||||
import SubscriptionHelper from "@server/models/helpers/SubscriptionHelper";
|
||||
import { authorize } from "@server/policies";
|
||||
import { presentSubscription } from "@server/presenters";
|
||||
@@ -26,18 +26,32 @@ router.post(
|
||||
validate(T.SubscriptionsListSchema),
|
||||
async (ctx: APIContext<T.SubscriptionsListReq>) => {
|
||||
const { user } = ctx.state.auth;
|
||||
const { documentId, event } = ctx.input.body;
|
||||
const { event, collectionId, documentId } = ctx.input.body;
|
||||
|
||||
const document = await Document.findByPk(documentId, { userId: user.id });
|
||||
const where: WhereOptions<Subscription> = {
|
||||
userId: user.id,
|
||||
event,
|
||||
};
|
||||
|
||||
authorize(user, "read", document);
|
||||
if (collectionId) {
|
||||
const collection = await Collection.scope({
|
||||
method: ["withMembership", user.id],
|
||||
}).findByPk(collectionId);
|
||||
authorize(user, "read", collection);
|
||||
|
||||
where.collectionId = collectionId;
|
||||
} else {
|
||||
// documentId will be available here
|
||||
const document = await Document.findByPk(documentId!, {
|
||||
userId: user.id,
|
||||
});
|
||||
authorize(user, "read", document);
|
||||
|
||||
where.documentId = documentId;
|
||||
}
|
||||
|
||||
const subscriptions = await Subscription.findAll({
|
||||
where: {
|
||||
documentId: document.id,
|
||||
userId: user.id,
|
||||
event,
|
||||
},
|
||||
where,
|
||||
order: [["createdAt", "DESC"]],
|
||||
offset: ctx.state.pagination.offset,
|
||||
limit: ctx.state.pagination.limit,
|
||||
@@ -56,19 +70,33 @@ router.post(
|
||||
validate(T.SubscriptionsInfoSchema),
|
||||
async (ctx: APIContext<T.SubscriptionsInfoReq>) => {
|
||||
const { user } = ctx.state.auth;
|
||||
const { documentId, event } = ctx.input.body;
|
||||
const { event, collectionId, documentId } = ctx.input.body;
|
||||
|
||||
const document = await Document.findByPk(documentId, { userId: user.id });
|
||||
const where: WhereOptions<Subscription> = {
|
||||
userId: user.id,
|
||||
event,
|
||||
};
|
||||
|
||||
authorize(user, "read", document);
|
||||
if (collectionId) {
|
||||
const collection = await Collection.scope({
|
||||
method: ["withMembership", user.id],
|
||||
}).findByPk(collectionId);
|
||||
authorize(user, "read", collection);
|
||||
|
||||
where.collectionId = collectionId;
|
||||
} else {
|
||||
// documentId will be available here
|
||||
const document = await Document.findByPk(documentId!, {
|
||||
userId: user.id,
|
||||
});
|
||||
authorize(user, "read", document);
|
||||
|
||||
where.documentId = documentId;
|
||||
}
|
||||
|
||||
// There can be only one subscription with these props.
|
||||
const subscription = await Subscription.findOne({
|
||||
where: {
|
||||
userId: user.id,
|
||||
documentId: document.id,
|
||||
event,
|
||||
},
|
||||
where,
|
||||
rejectOnEmpty: true,
|
||||
});
|
||||
|
||||
@@ -84,20 +112,28 @@ router.post(
|
||||
validate(T.SubscriptionsCreateSchema),
|
||||
transaction(),
|
||||
async (ctx: APIContext<T.SubscriptionsCreateReq>) => {
|
||||
const { transaction } = ctx.state;
|
||||
const { user } = ctx.state.auth;
|
||||
const { documentId, event } = ctx.input.body;
|
||||
const { event, collectionId, documentId } = ctx.input.body;
|
||||
|
||||
const document = await Document.findByPk(documentId, {
|
||||
userId: user.id,
|
||||
transaction,
|
||||
});
|
||||
if (collectionId) {
|
||||
const collection = await Collection.scope({
|
||||
method: ["withMembership", user.id],
|
||||
}).findByPk(collectionId);
|
||||
|
||||
authorize(user, "subscribe", document);
|
||||
authorize(user, "subscribe", collection);
|
||||
} else {
|
||||
// documentId will be available here
|
||||
const document = await Document.findByPk(documentId!, {
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
authorize(user, "subscribe", document);
|
||||
}
|
||||
|
||||
const subscription = await subscriptionCreator({
|
||||
ctx,
|
||||
documentId: document.id,
|
||||
documentId,
|
||||
collectionId,
|
||||
event,
|
||||
});
|
||||
|
||||
|
||||
+23
-4
@@ -7,6 +7,7 @@ import send from "koa-send";
|
||||
import userAgent, { UserAgentContext } from "koa-useragent";
|
||||
import { languages } from "@shared/i18n";
|
||||
import { IntegrationType, TeamPreference } from "@shared/types";
|
||||
import { parseDomain } from "@shared/utils/domains";
|
||||
import { Day } from "@shared/utils/time";
|
||||
import env from "@server/env";
|
||||
import { NotFoundError } from "@server/errors";
|
||||
@@ -138,11 +139,29 @@ router.get("*", shareDomains(), async (ctx, next) => {
|
||||
}
|
||||
|
||||
const team = await getTeamFromContext(ctx);
|
||||
let redirectUrl;
|
||||
|
||||
// Redirect all requests to custom domain if one is set
|
||||
if (team?.domain && team.domain !== ctx.hostname) {
|
||||
ctx.redirect(ctx.href.replace(ctx.hostname, team.domain));
|
||||
return;
|
||||
if (env.isCloudHosted) {
|
||||
// Redirect all requests to custom domain if one is set
|
||||
if (team?.domain && team.domain !== ctx.hostname) {
|
||||
redirectUrl = ctx.href.replace(ctx.hostname, team.domain);
|
||||
}
|
||||
|
||||
// Redirect if subdomain is not the current team's subdomain
|
||||
else if (team?.subdomain) {
|
||||
const { teamSubdomain } = parseDomain(ctx.href);
|
||||
if (team?.subdomain !== teamSubdomain) {
|
||||
redirectUrl = ctx.href.replace(
|
||||
`//${teamSubdomain}.`,
|
||||
`//${team.subdomain}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (redirectUrl) {
|
||||
ctx.redirect(redirectUrl);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const analytics = team
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Blob } from "buffer";
|
||||
import { Readable } from "stream";
|
||||
import { PresignedPost } from "@aws-sdk/s3-presigned-post";
|
||||
import { isBase64Url } from "@shared/utils/urls";
|
||||
import { isBase64Url, isInternalUrl } from "@shared/utils/urls";
|
||||
import env from "@server/env";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import fetch, { RequestInit } from "@server/utils/fetch";
|
||||
import fetch, { chromeUserAgent, RequestInit } from "@server/utils/fetch";
|
||||
|
||||
export default abstract class BaseStorage {
|
||||
/** The default number of seconds until a signed URL expires. */
|
||||
@@ -149,7 +149,7 @@ export default abstract class BaseStorage {
|
||||
const endpoint = this.getUploadUrl(true);
|
||||
|
||||
// Early return if url is already uploaded to the storage provider
|
||||
if (url.startsWith("/api") || url.startsWith(endpoint)) {
|
||||
if (url.startsWith(endpoint) || isInternalUrl(url)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -168,6 +168,9 @@ export default abstract class BaseStorage {
|
||||
options?.maxUploadSize ?? Infinity,
|
||||
env.FILE_STORAGE_UPLOAD_MAX_SIZE
|
||||
),
|
||||
headers: {
|
||||
"User-Agent": chromeUserAgent,
|
||||
},
|
||||
timeout: 10000,
|
||||
...init,
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
NotificationEventType,
|
||||
ProsemirrorData,
|
||||
ReactionSummary,
|
||||
SubscriptionType,
|
||||
UserRole,
|
||||
} from "@shared/types";
|
||||
import { parser, schema } from "@server/editor";
|
||||
@@ -120,7 +121,7 @@ export async function buildSubscription(overrides: Partial<Subscription> = {}) {
|
||||
overrides.userId = user.id;
|
||||
}
|
||||
|
||||
if (!overrides.documentId) {
|
||||
if (!overrides.documentId && !overrides.collectionId) {
|
||||
const document = await buildDocument({
|
||||
createdById: overrides.userId,
|
||||
teamId: user.teamId,
|
||||
@@ -129,7 +130,7 @@ export async function buildSubscription(overrides: Partial<Subscription> = {}) {
|
||||
}
|
||||
|
||||
return Subscription.create({
|
||||
event: "documents.update",
|
||||
event: SubscriptionType.Document,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
+6
-1
@@ -182,7 +182,6 @@ export type DocumentEvent = BaseEvent<Document> &
|
||||
name:
|
||||
| "documents.create"
|
||||
| "documents.publish"
|
||||
| "documents.unpublish"
|
||||
| "documents.delete"
|
||||
| "documents.permanent_delete"
|
||||
| "documents.archive"
|
||||
@@ -194,6 +193,11 @@ export type DocumentEvent = BaseEvent<Document> &
|
||||
source?: "import";
|
||||
};
|
||||
}
|
||||
| {
|
||||
name: "documents.unpublish";
|
||||
documentId: string;
|
||||
collectionId: string;
|
||||
}
|
||||
| {
|
||||
name: "documents.unarchive";
|
||||
documentId: string;
|
||||
@@ -423,6 +427,7 @@ export type SubscriptionEvent = BaseEvent<Subscription> & {
|
||||
modelId: string;
|
||||
userId: string;
|
||||
documentId: string | null;
|
||||
collectionId: string | null;
|
||||
};
|
||||
|
||||
export type ViewEvent = BaseEvent<View> & {
|
||||
|
||||
@@ -7,6 +7,13 @@ import Logger from "@server/logging/Logger";
|
||||
|
||||
export type { RequestInit } from "node-fetch";
|
||||
|
||||
/**
|
||||
* Fake Chrome user agent string for use in fetch requests to
|
||||
* improve reliability.
|
||||
*/
|
||||
export const chromeUserAgent =
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36";
|
||||
|
||||
/**
|
||||
* Wrapper around fetch that uses the request-filtering-agent in cloud hosted
|
||||
* environments to filter malicious requests, and the fetch-with-proxy library
|
||||
|
||||
@@ -127,9 +127,7 @@ export async function getTeamFromContext(ctx: Context) {
|
||||
} else if (domain.custom) {
|
||||
team = await Team.findOne({ where: { domain: domain.host } });
|
||||
} else if (domain.teamSubdomain) {
|
||||
team = await Team.findOne({
|
||||
where: { subdomain: domain.teamSubdomain },
|
||||
});
|
||||
team = await Team.findBySubdomain(domain.teamSubdomain);
|
||||
}
|
||||
|
||||
return team;
|
||||
|
||||
@@ -33,7 +33,7 @@ const SVG = ({ size, emoji }: { size: number; emoji: string }) => (
|
||||
<svg width={size} height={size} xmlns="http://www.w3.org/2000/svg">
|
||||
<text
|
||||
x="50%"
|
||||
y={"55%"}
|
||||
y="55%"
|
||||
dominantBaseline="middle"
|
||||
textAnchor="middle"
|
||||
fontSize={size * 0.7}
|
||||
|
||||
@@ -71,6 +71,7 @@ const Content = styled.p<{ $width: number; $isSelected: boolean }>`
|
||||
cursor: text;
|
||||
width: ${(props) => props.$width}px;
|
||||
min-width: 200px;
|
||||
max-width: 100%;
|
||||
|
||||
${breakpoint("tablet")`
|
||||
font-size: 13px;
|
||||
|
||||
@@ -55,6 +55,7 @@ const mathStyle = (props: Props) => css`
|
||||
cursor: auto;
|
||||
white-space: pre-wrap;
|
||||
overflow-x: auto;
|
||||
overflow-y: none;
|
||||
}
|
||||
|
||||
.math-node.empty-math .math-render::before {
|
||||
@@ -1095,6 +1096,7 @@ ol {
|
||||
direction: rtl;
|
||||
}
|
||||
|
||||
&:has(p:dir(rtl)),
|
||||
&:dir(rtl) {
|
||||
margin: 0 ${props.staticHTML ? "0" : "-26px"} 0 0.1em;
|
||||
padding: 0 48px 0 0;
|
||||
|
||||
@@ -16,17 +16,6 @@ test("returns true for bullet list", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true for numbered list", () => {
|
||||
expect(
|
||||
isMarkdown(`1. item one
|
||||
1. item two`)
|
||||
).toBe(true);
|
||||
expect(
|
||||
isMarkdown(`1. item one
|
||||
2. item two`)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true for code fence", () => {
|
||||
expect(
|
||||
isMarkdown(`\`\`\`javascript
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user