From b57f817d1185ba6543b6b3c07127fe99fcbd723a Mon Sep 17 00:00:00 2001 From: Simon Leigh Date: Tue, 27 Jul 2021 11:50:27 +0900 Subject: [PATCH 001/106] block api --- src/cloud/api/blocks/index.ts | 45 ++++++++++++++ src/cloud/lib/hooks/useDocBlocks.ts | 23 +++++++ src/cloud/lib/stores/blocks/index.ts | 92 ++++++++++++++++++++++++++++ 3 files changed, 160 insertions(+) create mode 100644 src/cloud/api/blocks/index.ts create mode 100644 src/cloud/lib/hooks/useDocBlocks.ts create mode 100644 src/cloud/lib/stores/blocks/index.ts diff --git a/src/cloud/api/blocks/index.ts b/src/cloud/api/blocks/index.ts new file mode 100644 index 0000000000..71aad9b873 --- /dev/null +++ b/src/cloud/api/blocks/index.ts @@ -0,0 +1,45 @@ +import { callApi } from '../../lib/client' + +export interface Block { + type: T + id: string + name: string + children: Block[] + data: D + doc: string +} + +export async function getDocBlocks(docId: string): Promise[]> { + const { blocks } = await callApi(`api/blocks`, { + search: { tree: true, doc: docId }, + }) + + return blocks +} + +export async function createBlock( + body: Omit, 'id'>, + parent?: string +): Promise> { + const { block } = await callApi(`api/blocks`, { + method: 'post', + json: { ...body, parent }, + }) + + return block +} + +export async function updateBlock( + body: Block +): Promise> { + const { block } = await callApi(`api/blocks/${body.id}`, { + method: 'put', + json: body, + }) + + return block +} + +export async function deleteBlock(id: string) { + await callApi(`api/blocks/${id}`, { method: 'delete' }) +} diff --git a/src/cloud/lib/hooks/useDocBlocks.ts b/src/cloud/lib/hooks/useDocBlocks.ts new file mode 100644 index 0000000000..ea11a1aa62 --- /dev/null +++ b/src/cloud/lib/hooks/useDocBlocks.ts @@ -0,0 +1,23 @@ +import { useBlocks } from '../stores/blocks' +import { useEffect, useState } from 'react' +import { Block } from '../../api/blocks' + +type BlockState = + | { type: 'loading' } + | { type: 'loaded'; blocks: Block[] } + +export function useDocBlocks(id: string) { + const { observeDocBlocks, ...actions } = useBlocks() + const [state, setState] = useState({ type: 'loading' }) + + useEffect(() => { + return observeDocBlocks(id, (blocks) => { + setState({ type: 'loaded', blocks }) + }) + }, [id, observeDocBlocks]) + + return { + state, + actions, + } +} diff --git a/src/cloud/lib/stores/blocks/index.ts b/src/cloud/lib/stores/blocks/index.ts new file mode 100644 index 0000000000..e16e4ba55e --- /dev/null +++ b/src/cloud/lib/stores/blocks/index.ts @@ -0,0 +1,92 @@ +import { createStoreContext } from '../../utils/context' +import { useToast } from '../../../../shared/lib/stores/toast' +import { useRef, useCallback } from 'react' +import { + Block, + getDocBlocks, + deleteBlock, + updateBlock, + createBlock, +} from '../../../api/blocks' + +type BlocksObserver = (blocks: Block[]) => void + +function useBlocksStore() { + const { pushApiErrorMessage } = useToast() + const blocksCache = useRef[]>>(new Map()) + const docBlockObservers = useRef>>(new Map()) + + const getBlocks = useCallback( + async (doc: string) => { + try { + const blocks = await getDocBlocks(doc) + blocksCache.current.set(doc, blocks) + const observers = docBlockObservers.current.get(doc) || new Set() + for (const observer of observers) { + observer(blocks) + } + return true + } catch (err) { + pushApiErrorMessage(err) + return false + } + }, + [pushApiErrorMessage] + ) + + const observeDocBlocks = useCallback( + (doc: string, observer: BlocksObserver) => { + const observers = docBlockObservers.current.get(doc) || new Set() + observers.add(observer) + docBlockObservers.current.set(doc, observers) + Promise.resolve(() => { + if (blocksCache.current.has(doc)) { + observer(blocksCache.current.get(doc)!) + } + }) + getBlocks(doc) + return () => { + observers.delete(observer) + } + }, + [getBlocks] + ) + + const create: typeof createBlock = useCallback( + async (body, parent) => { + const block = await createBlock(body, parent) + await getBlocks(block.doc) + return block + }, + [getBlocks] + ) + + const remove = useCallback( + async (block: Block) => { + await deleteBlock(block.id) + await getBlocks(block.doc) + }, + [getBlocks] + ) + + const update: typeof updateBlock = useCallback( + async (block) => { + const updated = await updateBlock(block) + await getBlocks(updated.doc) + return updated + }, + [getBlocks] + ) + + return { + observeDocBlocks, + create, + remove, + update, + } +} + +export const { + StoreProvider: BlocksProvider, + useStore: useBlocks, +} = createStoreContext(useBlocksStore, 'comments') From 600829ccbdd1e59376c27fd69cb2db00f1b346a3 Mon Sep 17 00:00:00 2001 From: Simon Leigh Date: Tue, 3 Aug 2021 12:05:07 +0900 Subject: [PATCH 002/106] blocks api --- src/cloud/api/blocks/index.ts | 48 ++++++++++---- src/cloud/api/teams/docs/index.ts | 1 + src/cloud/components/Blocks/BlockContent.tsx | 70 ++++++++++++++++++++ 3 files changed, 106 insertions(+), 13 deletions(-) create mode 100644 src/cloud/components/Blocks/BlockContent.tsx diff --git a/src/cloud/api/blocks/index.ts b/src/cloud/api/blocks/index.ts index 71aad9b873..d3740d2424 100644 --- a/src/cloud/api/blocks/index.ts +++ b/src/cloud/api/blocks/index.ts @@ -1,26 +1,50 @@ import { callApi } from '../../lib/client' -export interface Block { +interface BlockType< + T extends string, + D, + C extends BlockType = never +> { type: T id: string name: string - children: Block[] + children: C[] data: D - doc: string } -export async function getDocBlocks(docId: string): Promise[]> { - const { blocks } = await callApi(`api/blocks`, { - search: { tree: true, doc: docId }, +export type MarkdownBlock = BlockType<'markdown', null> +export type EmbedBlock = BlockType<'embed', { url: string }> +export type GithubIssueBlock = BlockType<'github.issue', any> +export type TableBlock = BlockType< + 'table', + { columns: Record }, + GithubIssueBlock +> +export type ContainerBlock = BlockType< + 'container', + null, + MarkdownBlock | EmbedBlock | TableBlock | ContainerBlock +> + +export type Block = + | MarkdownBlock + | EmbedBlock + | TableBlock + | ContainerBlock + | GithubIssueBlock + +export async function getBlockTree(rootBlock: string): Promise { + const { block } = await callApi(`api/blocks/${rootBlock}`, { + search: { tree: true }, }) - return blocks + return block } export async function createBlock( - body: Omit, 'id'>, - parent?: string -): Promise> { + body: Omit, + parent: string +): Promise { const { block } = await callApi(`api/blocks`, { method: 'post', json: { ...body, parent }, @@ -29,9 +53,7 @@ export async function createBlock( return block } -export async function updateBlock( - body: Block -): Promise> { +export async function updateBlock(body: Block): Promise { const { block } = await callApi(`api/blocks/${body.id}`, { method: 'put', json: body, diff --git a/src/cloud/api/teams/docs/index.ts b/src/cloud/api/teams/docs/index.ts index 48117f8473..e1da47fbb6 100644 --- a/src/cloud/api/teams/docs/index.ts +++ b/src/cloud/api/teams/docs/index.ts @@ -22,6 +22,7 @@ export interface CreateDocRequestBody { template?: string title?: string emoji?: string + blocks?: boolean } export interface CreateDocResponseBody { diff --git a/src/cloud/components/Blocks/BlockContent.tsx b/src/cloud/components/Blocks/BlockContent.tsx new file mode 100644 index 0000000000..395ddff70c --- /dev/null +++ b/src/cloud/components/Blocks/BlockContent.tsx @@ -0,0 +1,70 @@ +import React, { useCallback } from 'react' +import { SerializedDoc } from '../../../interfaces/db/doc' +import { useDocBlocks } from '../../../lib/hooks/useDocBlocks' +import { Block } from '../../../api/blocks' +import { capitalize } from '../../../../lib/string' + +const BlockContent = (doc: SerializedDoc) => { + const { state, actions } = useDocBlocks(doc.id) + + const createContainer = useCallback(() => { + return actions.create({ + name: 'container', + type: 'container', + doc: doc.id, + children: [], + data: null, + }) + }, [doc, actions]) + + if (state.type === 'loading') { + return
loading
+ } + + return ( +
+
+ +
+
New Items
+
Container
+
Table
+
Embed
+
+
+
+ +
Add Block
+
+
+ ) +} + +interface BlockTreeProps { + blocks: Block[] +} + +const BlockTree = ({ blocks }: BlockTreeProps) => { + return ( +
+ {blocks.map((block) => { + return ( +
+
{capitalize(block.type)}
+ {block.children.length > 0 && } +
+ ) + })} +
+ ) +} + +interface BlockViewProps { + blocks: Block[] +} + +const BlockView = ({ blocks }: BlockViewProps) => { + return
{JSON.stringify(blocks)}
+} + +export default BlockContent From aef05e78db0d23c889cd3ec66731d258984198aa Mon Sep 17 00:00:00 2001 From: Simon Leigh Date: Tue, 3 Aug 2021 12:05:23 +0900 Subject: [PATCH 003/106] integration action api --- src/cloud/api/integrations/index.ts | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/cloud/api/integrations/index.ts b/src/cloud/api/integrations/index.ts index f2ff343f89..9f08fd9e02 100644 --- a/src/cloud/api/integrations/index.ts +++ b/src/cloud/api/integrations/index.ts @@ -16,3 +16,29 @@ export async function deleteTeamIntegration( ) { return callApi(`api/integrations/${integration.id}`, { method: 'delete' }) } + +export interface IntegrationActionTypes { + ['orgs:list']: { + id: string + login: string + }[] + ['org:repos']: { + id: string + name: string + owner: { + login: string + } + }[] + ['repo:issues']: any[] +} + +export async function getAction( + integration: SerializedTeamIntegration, + action: A, + args?: Record +): Promise { + const { data } = await callApi(`/api/integrations/${integration.id}/action`, { + search: { action, ...args }, + }) + return data +} From ce0de747c73ee7c1048146c2e84ff5bf5dc28347 Mon Sep 17 00:00:00 2001 From: Simon Leigh Date: Tue, 3 Aug 2021 12:05:48 +0900 Subject: [PATCH 004/106] update doc interface --- src/cloud/interfaces/db/doc.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/cloud/interfaces/db/doc.ts b/src/cloud/interfaces/db/doc.ts index 8948e08b50..a2026855d3 100644 --- a/src/cloud/interfaces/db/doc.ts +++ b/src/cloud/interfaces/db/doc.ts @@ -5,6 +5,7 @@ import type { SerializedTag } from './tag' import { SerializedWorkspace } from './workspace' import { SerializedShareLink } from './shareLink' import { SerializedUser } from './user' +import { ContainerBlock } from '../../api/blocks' export type DocStatus = 'in_progress' | 'completed' | 'archived' | 'paused' @@ -30,6 +31,7 @@ export interface SerializableDocProps { dueDate: string assignees?: SerializedDocAssignee[] userId?: string + rootBlock?: ContainerBlock } export interface SerializedUnserializableDocProps { From b299b7c0f2e4f4ecd04a8e65ede1d85d18c59d84 Mon Sep 17 00:00:00 2001 From: Simon Leigh Date: Tue, 3 Aug 2021 12:08:03 +0900 Subject: [PATCH 005/106] blocks store --- src/cloud/components/Router.tsx | 4 +- .../lib/hooks/useCloudResourceModals.tsx | 2 + src/cloud/lib/hooks/useDocBlocks.ts | 30 +++++++++--- src/cloud/lib/stores/blocks/index.ts | 48 +++++++++---------- 4 files changed, 52 insertions(+), 32 deletions(-) diff --git a/src/cloud/components/Router.tsx b/src/cloud/components/Router.tsx index 31ebb7c095..83cf3430b4 100644 --- a/src/cloud/components/Router.tsx +++ b/src/cloud/components/Router.tsx @@ -62,6 +62,7 @@ import Spinner from '../../design/components/atoms/Spinner' import { TeamPreferencesProvider } from '../lib/stores/teamPreferences' import Application from './Application' import { BaseTheme } from '../../design/lib/styled/types' +import { BlocksProvider } from '../lib/stores/blocks' const CombinedProvider = combineProviders( TeamStorageProvider, @@ -84,7 +85,8 @@ const V2CombinedProvider = combineProviders( V2DialogProvider, CommentsProvider, NotificationsProvider, - TeamIntegrationsProvider + TeamIntegrationsProvider, + BlocksProvider ) interface PageInfo { diff --git a/src/cloud/lib/hooks/useCloudResourceModals.tsx b/src/cloud/lib/hooks/useCloudResourceModals.tsx index e31e6e6531..ac20782b2d 100644 --- a/src/cloud/lib/hooks/useCloudResourceModals.tsx +++ b/src/cloud/lib/hooks/useCloudResourceModals.tsx @@ -182,6 +182,7 @@ export function useCloudResourceModals() { parentFolderId: body.parentFolderId, title: inputValue, emoji, + blocks: body.blocks, }, { skipRedirect: options?.skipRedirect, @@ -302,6 +303,7 @@ export interface CloudNewResourceRequestBody { team?: SerializedTeam workspaceId?: string parentFolderId?: string + blocks?: boolean } export type UIFormOptions = SubmissionWrappers & { diff --git a/src/cloud/lib/hooks/useDocBlocks.ts b/src/cloud/lib/hooks/useDocBlocks.ts index ea11a1aa62..f94279aba4 100644 --- a/src/cloud/lib/hooks/useDocBlocks.ts +++ b/src/cloud/lib/hooks/useDocBlocks.ts @@ -1,21 +1,37 @@ import { useBlocks } from '../stores/blocks' -import { useEffect, useState } from 'react' +import { useEffect, useState, useMemo } from 'react' import { Block } from '../../api/blocks' -type BlockState = - | { type: 'loading' } - | { type: 'loaded'; blocks: Block[] } +type BlockState = { type: 'loading' } | { type: 'loaded'; block: Block } + +export interface BlockActions { + create: (block: Omit, parent: Block) => Promise + update: (block: Block) => Promise + remove: (block: Block) => Promise +} export function useDocBlocks(id: string) { - const { observeDocBlocks, ...actions } = useBlocks() + const { observeDocBlocks, create, update, remove } = useBlocks() const [state, setState] = useState({ type: 'loading' }) useEffect(() => { - return observeDocBlocks(id, (blocks) => { - setState({ type: 'loaded', blocks }) + const unsub = observeDocBlocks(id, (block) => { + setState({ type: 'loaded', block }) }) + return () => { + setState({ type: 'loading' }) + unsub() + } }, [id, observeDocBlocks]) + const actions: BlockActions = useMemo(() => { + return { + create: (block, parent) => create(block, parent, id), + update: (block) => update(block, id), + remove: (block) => remove(block, id), + } + }, [id, create, update, remove]) + return { state, actions, diff --git a/src/cloud/lib/stores/blocks/index.ts b/src/cloud/lib/stores/blocks/index.ts index e16e4ba55e..8e37f6588c 100644 --- a/src/cloud/lib/stores/blocks/index.ts +++ b/src/cloud/lib/stores/blocks/index.ts @@ -3,25 +3,25 @@ import { useToast } from '../../../../shared/lib/stores/toast' import { useRef, useCallback } from 'react' import { Block, - getDocBlocks, + getBlockTree, deleteBlock, updateBlock, createBlock, } from '../../../api/blocks' -type BlocksObserver = (blocks: Block[]) => void +type BlocksObserver = (blocks: Block) => void function useBlocksStore() { const { pushApiErrorMessage } = useToast() - const blocksCache = useRef[]>>(new Map()) - const docBlockObservers = useRef>>(new Map()) + const treeCache = useRef>(new Map()) + const treeObservers = useRef>>(new Map()) const getBlocks = useCallback( - async (doc: string) => { + async (rootBlock: string) => { try { - const blocks = await getDocBlocks(doc) - blocksCache.current.set(doc, blocks) - const observers = docBlockObservers.current.get(doc) || new Set() + const blocks = await getBlockTree(rootBlock) + treeCache.current.set(rootBlock, blocks) + const observers = treeObservers.current.get(rootBlock) || new Set() for (const observer of observers) { observer(blocks) } @@ -35,16 +35,16 @@ function useBlocksStore() { ) const observeDocBlocks = useCallback( - (doc: string, observer: BlocksObserver) => { - const observers = docBlockObservers.current.get(doc) || new Set() + (rootBlock: string, observer: BlocksObserver) => { + const observers = treeObservers.current.get(rootBlock) || new Set() observers.add(observer) - docBlockObservers.current.set(doc, observers) + treeObservers.current.set(rootBlock, observers) Promise.resolve(() => { - if (blocksCache.current.has(doc)) { - observer(blocksCache.current.get(doc)!) + if (treeCache.current.has(rootBlock)) { + observer(treeCache.current.get(rootBlock)!) } }) - getBlocks(doc) + getBlocks(rootBlock) return () => { observers.delete(observer) } @@ -52,27 +52,27 @@ function useBlocksStore() { [getBlocks] ) - const create: typeof createBlock = useCallback( - async (body, parent) => { - const block = await createBlock(body, parent) - await getBlocks(block.doc) + const create = useCallback( + async (body: Omit, parent: Block, root: string) => { + const block = await createBlock(body, parent.id) + await getBlocks(root) return block }, [getBlocks] ) const remove = useCallback( - async (block: Block) => { + async (block: Block, root: string) => { await deleteBlock(block.id) - await getBlocks(block.doc) + await getBlocks(root) }, [getBlocks] ) - const update: typeof updateBlock = useCallback( - async (block) => { + const update = useCallback( + async (block: Block, root: string) => { const updated = await updateBlock(block) - await getBlocks(updated.doc) + await getBlocks(root) return updated }, [getBlocks] @@ -89,4 +89,4 @@ function useBlocksStore() { export const { StoreProvider: BlocksProvider, useStore: useBlocks, -} = createStoreContext(useBlocksStore, 'comments') +} = createStoreContext(useBlocksStore, 'blocks') From aa58063090c0e13f1135b9775f7cb2d604abee96 Mon Sep 17 00:00:00 2001 From: Simon Leigh Date: Tue, 3 Aug 2021 12:08:37 +0900 Subject: [PATCH 006/106] Update new doc button --- src/cloud/components/buttons/NewDocButton.tsx | 85 ++++++++++++++----- src/cloud/lib/i18n/enUS.ts | 1 + src/cloud/lib/i18n/types.ts | 1 + 3 files changed, 65 insertions(+), 22 deletions(-) diff --git a/src/cloud/components/buttons/NewDocButton.tsx b/src/cloud/components/buttons/NewDocButton.tsx index efc354b2de..5970aa6122 100644 --- a/src/cloud/components/buttons/NewDocButton.tsx +++ b/src/cloud/components/buttons/NewDocButton.tsx @@ -1,8 +1,16 @@ -import { mdiPencilBoxMultipleOutline, mdiTextBoxPlus } from '@mdi/js' -import React from 'react' import SidebarButton from '../../../design/components/organisms/Sidebar/atoms/SidebarButton' -import { MenuTypes } from '../../../design/lib/stores/contextMenu' +import { + MenuTypes, + useContextMenu, +} from '../../../design/lib/stores/contextMenu' import { useModal } from '../../../design/lib/stores/modal' +import { + mdiPencilBoxMultipleOutline, + mdiPackageVariantClosed, + mdiTextBoxPlusOutline, + mdiPencilBoxOutline, +} from '@mdi/js' +import React, { useCallback } from 'react' import { SerializedTeam } from '../../interfaces/db/team' import { useCloudResourceModals } from '../../lib/hooks/useCloudResourceModals' import { useI18n } from '../../lib/hooks/useI18n' @@ -20,31 +28,64 @@ const NewDocButton = ({ team }: { team: SerializedTeam }) => { const { openNewDocForm } = useCloudResourceModals() const { openModal } = useModal() const { translate } = useI18n() + const { popup } = useContextMenu() + const openNewDocModal = useCallback( + (isCanvas = false) => { + openNewDocForm( + { + team, + parentFolderId: currentParentFolderId, + workspaceId: currentWorkspaceId, + blocks: isCanvas, + }, + { + precedingRows: [ + { + description: `${ + workspacesMap.get(currentWorkspaceId || '')?.name + }${currentPath}`, + }, + ], + } + ) + }, + [ + openNewDocForm, + workspacesMap, + currentWorkspaceId, + team, + currentParentFolderId, + currentPath, + ] + ) + + const openDocTypeSelect: React.MouseEventHandler = useCallback( + (ev) => { + popup(ev, [ + { + icon: mdiTextBoxPlusOutline, + type: MenuTypes.Normal, + label: translate(lngKeys.CreateNewDoc), + onClick: () => openNewDocModal(), + }, + { + icon: mdiPackageVariantClosed, + type: MenuTypes.Normal, + label: translate(lngKeys.CreateNewCanvas), + onClick: () => openNewDocModal(true), + }, + ]) + }, + [openNewDocModal, popup, translate] + ) return ( - openNewDocForm( - { - team, - parentFolderId: currentParentFolderId, - workspaceId: currentWorkspaceId, - }, - { - precedingRows: [ - { - description: `${ - workspacesMap.get(currentWorkspaceId || '')?.name - }${currentPath}`, - }, - ], - } - ) - } + labelClick={openDocTypeSelect} contextControls={[ { icon: mdiPencilBoxMultipleOutline, diff --git a/src/cloud/lib/i18n/enUS.ts b/src/cloud/lib/i18n/enUS.ts index c2b5198581..bafcf34363 100644 --- a/src/cloud/lib/i18n/enUS.ts +++ b/src/cloud/lib/i18n/enUS.ts @@ -239,6 +239,7 @@ const enTranslation: TranslationSource = { [lngKeys.SortTitleZA]: 'Title Z-A', [lngKeys.SortDragAndDrop]: 'Drag and Drop', [lngKeys.CreateNewDoc]: 'Create new doc', + [lngKeys.CreateNewCanvas]: 'Create new canvas (beta)', [lngKeys.UseATemplate]: 'Use a template', [lngKeys.RenameFolder]: 'Rename folder', [lngKeys.RenameDoc]: 'Rename doc', diff --git a/src/cloud/lib/i18n/types.ts b/src/cloud/lib/i18n/types.ts index 103db2c255..dda4ffdceb 100644 --- a/src/cloud/lib/i18n/types.ts +++ b/src/cloud/lib/i18n/types.ts @@ -284,6 +284,7 @@ export enum lngKeys { SortTitleZA = 'sort.z-a', SortDragAndDrop = 'sort.drag', CreateNewDoc = 'create.new.doc', + CreateNewCanvas = 'create.new.canvas', UseATemplate = 'use.a.template', RenameFolder = 'Rename.folder', RenameDoc = 'Rename.doc', From bb87be2780460a9dbd0f5d407c475bf7e72ece83 Mon Sep 17 00:00:00 2001 From: Simon Leigh Date: Tue, 3 Aug 2021 12:10:22 +0900 Subject: [PATCH 007/106] new github integration page --- src/cloud/components/Blocks/BlockContent.tsx | 27 +-- .../components/settings/GithubIntegration.tsx | 171 ++--------------- .../settings/IntegrationManager.tsx | 176 ++++++++++++++++++ .../components/settings/SlackIntegration.tsx | 172 +---------------- .../organisms/Sidebar/atoms/SidebarButton.tsx | 4 +- 5 files changed, 214 insertions(+), 336 deletions(-) create mode 100644 src/cloud/components/settings/IntegrationManager.tsx diff --git a/src/cloud/components/Blocks/BlockContent.tsx b/src/cloud/components/Blocks/BlockContent.tsx index 395ddff70c..58dfefb9cb 100644 --- a/src/cloud/components/Blocks/BlockContent.tsx +++ b/src/cloud/components/Blocks/BlockContent.tsx @@ -1,20 +1,23 @@ import React, { useCallback } from 'react' -import { SerializedDoc } from '../../../interfaces/db/doc' -import { useDocBlocks } from '../../../lib/hooks/useDocBlocks' -import { Block } from '../../../api/blocks' -import { capitalize } from '../../../../lib/string' +import { Block } from '../../api/blocks' +import { SerializedDoc } from '../../interfaces/db/doc' +import { useDocBlocks } from '../../lib/hooks/useDocBlocks' const BlockContent = (doc: SerializedDoc) => { const { state, actions } = useDocBlocks(doc.id) - const createContainer = useCallback(() => { - return actions.create({ - name: 'container', - type: 'container', - doc: doc.id, - children: [], - data: null, - }) + const createContainer = useCallback(async () => { + if (doc.rootBlock != null) { + await actions.create( + { + name: 'container', + type: 'container', + children: [], + data: null, + }, + doc.rootBlock + ) + } }, [doc, actions]) if (state.type === 'loading') { diff --git a/src/cloud/components/settings/GithubIntegration.tsx b/src/cloud/components/settings/GithubIntegration.tsx index 1f5263cda7..52bef25478 100644 --- a/src/cloud/components/settings/GithubIntegration.tsx +++ b/src/cloud/components/settings/GithubIntegration.tsx @@ -1,172 +1,23 @@ -import React, { useCallback, useMemo } from 'react' -import ServiceConnect, { Integration } from '../ServiceConnect' -import { - useServiceConnections, - withServiceConnections, -} from '../../lib/stores/serviceConnections' -import { githubOauthId, boostHubBaseUrl } from '../../lib/consts' -import { usingElectron } from '../../lib/stores/electron' -import { openNew } from '../../lib/utils/platform' -import { usePage } from '../../lib/stores/pageStore' +import React from 'react' import SettingTabContent from '../../../design/components/organisms/Settings/atoms/SettingTabContent' -import Button, { LoadingButton } from '../../../design/components/atoms/Button' -import { useI18n } from '../../lib/hooks/useI18n' -import { lngKeys } from '../../lib/i18n/types' -import styled from '../../../design/lib/styled' +import IntegrationManager from './IntegrationManager' const GithubIntegrations = () => { - const connectionState = useServiceConnections() - const { team } = usePage() - const { translate } = useI18n() - - const githubConnection = useMemo(() => { - return connectionState.type !== 'initialising' - ? connectionState.connections.find((conn) => conn.service === 'github') - : null - }, [connectionState]) - - const removeGithubConnection = useCallback(() => { - if (connectionState.type !== 'initialising' && githubConnection != null) { - connectionState.actions.removeConnection(githubConnection) - } - }, [githubConnection, connectionState]) - - const addConnection = useCallback( - (integration: Integration) => { - if ( - connectionState.type !== 'initialising' && - integration.type === 'user' - ) { - connectionState.actions.addConnection(integration.integration) - } - }, - [connectionState] - ) - return ( -
-

How does it work?

-

- Embed the issues and pull requests in GitHub private repository - into Boost Note documents -

-
-
-
- GitHub -

Connect Boost Note with GitHub private repository

-
- {(connectionState.type === 'initialising' || - connectionState.type === 'working') && ( - - )} - {connectionState.type === 'initialised' && ( - <> - {githubConnection == null ? ( - usingElectron ? ( - - ) : ( - - {translate(lngKeys.GeneralEnableVerb)} - - ) - ) : ( - - )} - - )} -
-
- + +

How does it work?

+

Create Github Issue blocks in Canvas Documents (beta)

+
} /> ) } -const StyledGithubIntegration = styled.div` - & .integration__connect { - display: flex; - align-items: center; - justify-content: space-between; - padding: ${({ theme }) => theme.sizes.spaces.sm}px; - background-color: ${({ theme }) => theme.colors.background.primary}; - border: 1px solid ${({ theme }) => theme.colors.border.main}; - - & .integration__connect__info { - display: flex; - align-items: center; - } - img { - height: 30px; - margin-right: ${({ theme }) => theme.sizes.spaces.sm}px; - } - - p { - margin: 0; - } - } - - & .integration__connect__meta { - display: flex; - justify-content: flex-end; - font-size: ${({ theme }) => theme.sizes.fonts.sm}px; - margin-top: ${({ theme }) => theme.sizes.spaces.xsm}px; - color: ${({ theme }) => theme.colors.text.subtle}; - a { - color: ${({ theme }) => theme.colors.text.link}; - text-decoration: underline; - - &:hover, - &:focus { - text-decoration: none; - } - } - } - - .integration__description { - padding: ${({ theme }) => theme.sizes.spaces.df}px 0; - - p { - color: ${({ theme }) => theme.colors.text.subtle}; - } - } -` - -export default withServiceConnections(GithubIntegrations) +export default GithubIntegrations diff --git a/src/cloud/components/settings/IntegrationManager.tsx b/src/cloud/components/settings/IntegrationManager.tsx new file mode 100644 index 0000000000..890a32f11e --- /dev/null +++ b/src/cloud/components/settings/IntegrationManager.tsx @@ -0,0 +1,176 @@ +import React, { useCallback, useMemo } from 'react' +import Button from '../../../design/components/atoms/Button' +import Spinner from '../../../design/components/atoms/Spinner' +import { useTeamIntegrations } from '../../../design/lib/stores/integrations' +import styled from '../../../design/lib/styled' +import { boostHubBaseUrl } from '../../lib/consts' +import { useI18n } from '../../lib/hooks/useI18n' +import { lngKeys } from '../../lib/i18n/types' +import { usingElectron } from '../../lib/stores/electron' +import { usePage } from '../../lib/stores/pageStore' +import { openNew } from '../../lib/utils/platform' +import ServiceConnect, { Integration } from '../ServiceConnect' + +interface IntegrationManagerProps { + service: string + icon: string + name: string +} + +const IntegrationManager = ({ + service, + icon, + name, + children, +}: React.PropsWithChildren) => { + const integrationState = useTeamIntegrations() + const { team } = usePage() + const { translate } = useI18n() + + const addIntegration = useCallback( + (integration: Integration) => { + if ( + integrationState.type !== 'initialising' && + integration.type === 'team' + ) { + integrationState.actions.addIntegration(integration.integration) + } + }, + [integrationState] + ) + + const integrations = useMemo(() => { + if (integrationState.type === 'initialising') { + return [] + } + + return integrationState.integrations.filter( + (integration) => integration.service === service + ) + }, [integrationState, service]) + + return ( + +
{children}
+
+
+
+ {name} +

Connect Boost Note with {name}

+
+ {(integrationState.type === 'initialising' || + integrationState.type === 'working') && ( + + )} + {integrationState.type === 'initialised' && + (usingElectron ? ( + + ) : ( + + {translate(lngKeys.GeneralAddVerb)} + + ))} +
+ {integrationState.type !== 'initialising' && ( + <> + {integrations.length > 0 &&

Connected Teams:

} + {integrations.map((integration) => ( +
+

{integration.name}

+ + +
+ ))} + + )} +
+
+ ) +} + +const StyledIntegrationManager = styled.div` + & .integration__content { + padding: ${({ theme }) => theme.sizes.spaces.sm}px; + background-color: ${({ theme }) => theme.colors.background.primary}; + border: 1px solid ${({ theme }) => theme.colors.border.main}; + } + + & .integration__list__item { + display: flex; + align-items: center; + justify-content: space-between; + padding-left: ${({ theme }) => theme.sizes.spaces.sm}px; + } + + & .integration__connect { + display: flex; + align-items: center; + justify-content: space-between; + + & .integration__connect__info { + display: flex; + align-items: center; + } + img { + height: 30px; + margin-right: ${({ theme }) => theme.sizes.spaces.sm}px; + } + + p { + margin: 0; + } + } + + & .integration__connect__meta { + display: flex; + justify-content: flex-end; + font-size: ${({ theme }) => theme.sizes.fonts.sm}px; + margin-top: ${({ theme }) => theme.sizes.spaces.xsm}px; + color: ${({ theme }) => theme.colors.text.subtle}; + a { + color: ${({ theme }) => theme.colors.text.primary}; + text-decoration: underline; + + &:hover, + &:focus { + text-decoration: none; + } + } + } + + .integration__description { + padding: ${({ theme }) => theme.sizes.spaces.df}px 0; + + h3 { + margin-top: 0; + } + + p { + color: ${({ theme }) => theme.colors.text.subtle}; + } + } +` + +export default IntegrationManager diff --git a/src/cloud/components/settings/SlackIntegration.tsx b/src/cloud/components/settings/SlackIntegration.tsx index 0169e4cec0..32d2784e7c 100644 --- a/src/cloud/components/settings/SlackIntegration.tsx +++ b/src/cloud/components/settings/SlackIntegration.tsx @@ -1,175 +1,23 @@ -import React, { useCallback, useMemo } from 'react' -import ServiceConnect, { Integration } from '../ServiceConnect' -import { boostHubBaseUrl } from '../../lib/consts' -import { usingElectron } from '../../lib/stores/electron' -import { openNew } from '../../lib/utils/platform' -import { usePage } from '../../lib/stores/pageStore' +import React from 'react' import SettingTabContent from '../../../design/components/organisms/Settings/atoms/SettingTabContent' -import Button, { LoadingButton } from '../../../design/components/atoms/Button' -import { useI18n } from '../../lib/hooks/useI18n' -import { lngKeys } from '../../lib/i18n/types' -import { useTeamIntegrations } from '../../../design/lib/stores/integrations' -import styled from '../../../design/lib/styled' +import IntegrationManager from './IntegrationManager' const SlackIntegration = () => { - const integrationState = useTeamIntegrations() - const { team } = usePage() - const { translate } = useI18n() - - const addIntegration = useCallback( - (integration: Integration) => { - if ( - integrationState.type !== 'initialising' && - integration.type === 'team' - ) { - integrationState.actions.addIntegration(integration.integration) - } - }, - [integrationState] - ) - - const slackIntegrations = useMemo(() => { - if (integrationState.type === 'initialising') { - return [] - } - - return integrationState.integrations.filter( - (integration) => integration.service === 'slack:team' - ) - }, [integrationState]) - return ( -
-

How does it work?

-

Show Boost Note document preview in Slack

-
-
-
-
- GitHub -

Connect Boost Note with Slack

-
- {(integrationState.type === 'initialising' || - integrationState.type === 'working') && ( - - )} - {integrationState.type === 'initialised' && - (usingElectron ? ( - - ) : ( - - {translate(lngKeys.GeneralAddVerb)} - - ))} -
- {integrationState.type !== 'initialising' && ( - <> - {slackIntegrations.length > 0 &&

Connected Teams:

} - {slackIntegrations.map((integration) => ( -
-

{integration.name}

- - -
- ))} - - )} -
- + +

How does it work?

+

Show Boost Note document preview in Slack

+
} /> ) } -const StyledGithubIntegration = styled.div` - & .integration__content { - padding: ${({ theme }) => theme.sizes.spaces.sm}px; - background-color: ${({ theme }) => theme.colors.background.primary}; - border: 1px solid ${({ theme }) => theme.colors.border.main}; - } - - & .integration__list__item { - display: flex; - align-items: center; - justify-content: space-between; - padding-left: ${({ theme }) => theme.sizes.spaces.sm}px; - } - - & .integration__connect { - display: flex; - align-items: center; - justify-content: space-between; - - & .integration__connect__info { - display: flex; - align-items: center; - } - img { - height: 30px; - margin-right: ${({ theme }) => theme.sizes.spaces.sm}px; - } - - p { - margin: 0; - } - } - - & .integration__connect__meta { - display: flex; - justify-content: flex-end; - font-size: ${({ theme }) => theme.sizes.fonts.sm}px; - margin-top: ${({ theme }) => theme.sizes.spaces.xsm}px; - color: ${({ theme }) => theme.colors.text.subtle}; - a { - color: ${({ theme }) => theme.colors.text.link}; - text-decoration: underline; - - &:hover, - &:focus { - text-decoration: none; - } - } - } - - .integration__description { - padding: ${({ theme }) => theme.sizes.spaces.df}px 0; - - h3 { - margin-top: 0; - } - - p { - color: ${({ theme }) => theme.colors.text.subtle}; - } - } -` - export default SlackIntegration diff --git a/src/design/components/organisms/Sidebar/atoms/SidebarButton.tsx b/src/design/components/organisms/Sidebar/atoms/SidebarButton.tsx index 3bdaab06cd..43adeee97d 100644 --- a/src/design/components/organisms/Sidebar/atoms/SidebarButton.tsx +++ b/src/design/components/organisms/Sidebar/atoms/SidebarButton.tsx @@ -15,7 +15,7 @@ export interface SidebarButtonProps { id: string label: string | React.ReactNode labelHref?: string - labelClick?: () => void + labelClick?: React.MouseEventHandler pastille?: string | number contextControls?: MenuItem[] active?: boolean @@ -51,7 +51,7 @@ const SidebarButton: AppComponent = ({ return } event.preventDefault() - labelClick() + labelClick(event) }, [labelClick] ) From 5792b4f5ce25f192f1c1042859ca50662e25c823 Mon Sep 17 00:00:00 2001 From: Simon Leigh Date: Tue, 3 Aug 2021 12:11:04 +0900 Subject: [PATCH 008/106] Embed Block View & Form --- .../components/Blocks/forms/EmbedForm.tsx | 77 +++++++++++++++++++ src/cloud/components/Blocks/views/Embed.tsx | 28 +++++++ 2 files changed, 105 insertions(+) create mode 100644 src/cloud/components/Blocks/forms/EmbedForm.tsx create mode 100644 src/cloud/components/Blocks/views/Embed.tsx diff --git a/src/cloud/components/Blocks/forms/EmbedForm.tsx b/src/cloud/components/Blocks/forms/EmbedForm.tsx new file mode 100644 index 0000000000..87f6460c26 --- /dev/null +++ b/src/cloud/components/Blocks/forms/EmbedForm.tsx @@ -0,0 +1,77 @@ +import React, { useRef, useState, useCallback } from 'react' +import { useEffectOnce } from 'react-use' +import Form from '../../../../design/components/molecules/Form' +import { Block } from '../../../api/blocks' +import { useI18n } from '../../../lib/hooks/useI18n' +import { lngKeys } from '../../../lib/i18n/types' + +interface EmbedFormProps { + onSubmit: (block: Omit, 'id'>) => Promise +} +const EmbedForm = ({ onSubmit }: EmbedFormProps) => { + const inputRef = useRef(null) + const [name, setName] = useState('') + const [url, setUrl] = useState('') + const [inputDisabled, setInputDisabled] = useState(false) + const { translate } = useI18n() + + const submit = useCallback(async () => { + try { + setInputDisabled(true) + await onSubmit({ + name, + type: 'embed', + children: [], + data: { url }, + }) + } finally { + setInputDisabled(false) + } + }, [onSubmit, name, url]) + + useEffectOnce(() => { + if (inputRef.current != null && !inputDisabled) { + inputRef.current.focus() + } + }) + + return ( +
setName(event.target.value), + }, + }, + ], + }, + { + title: 'Embed URL', + items: [ + { + type: 'input', + props: { + disabled: inputDisabled, + placeholder: '', + name, + onChange: (event) => setUrl(event.target.value), + }, + }, + ], + }, + ]} + submitButton={{ label: translate(lngKeys.GeneralCreate) }} + onSubmit={submit} + /> + ) +} + +export default EmbedForm diff --git a/src/cloud/components/Blocks/views/Embed.tsx b/src/cloud/components/Blocks/views/Embed.tsx new file mode 100644 index 0000000000..81541919c8 --- /dev/null +++ b/src/cloud/components/Blocks/views/Embed.tsx @@ -0,0 +1,28 @@ +import React from 'react' +import { ViewProps } from '../BlockContent' +import { EmbedBlock } from '../../../../api/blocks' +import styled from '../../../../lib/styled' + +const EmbedView = ({ block }: ViewProps) => { + return ( + + + + ) +} + +const StyledEmbedView = styled.div` + width: 100%; + position: relative; + padding-top: 56.25%; + + & iframe { + position: absolute; + top: 0; + left: 0; + height: 100%; + width: 100%; + } +` + +export default EmbedView From 1081798a7bad55bb716d717519e83bf51667581f Mon Sep 17 00:00:00 2001 From: Simon Leigh Date: Tue, 3 Aug 2021 12:11:41 +0900 Subject: [PATCH 009/106] Markdown Block View & Form --- .../components/Blocks/forms/MarkdownForm.tsx | 60 +++++++++++++ .../components/Blocks/views/Markdown.tsx | 89 +++++++++++++++++++ 2 files changed, 149 insertions(+) create mode 100644 src/cloud/components/Blocks/forms/MarkdownForm.tsx create mode 100644 src/cloud/components/Blocks/views/Markdown.tsx diff --git a/src/cloud/components/Blocks/forms/MarkdownForm.tsx b/src/cloud/components/Blocks/forms/MarkdownForm.tsx new file mode 100644 index 0000000000..4f70849994 --- /dev/null +++ b/src/cloud/components/Blocks/forms/MarkdownForm.tsx @@ -0,0 +1,60 @@ +import React, { useRef, useState, useCallback } from 'react' +import { useEffectOnce } from 'react-use' +import Form from '../../../../design/components/molecules/Form' +import { MarkdownBlock } from '../../../api/blocks' +import { useI18n } from '../../../lib/hooks/useI18n' +import { lngKeys } from '../../../lib/i18n/types' +import { FormProps } from '../BlockContent' + +const MarkdownForm = ({ onSubmit }: FormProps) => { + const inputRef = useRef(null) + const [value, setValue] = useState('') + const [inputDisabled, setInputDisabled] = useState(false) + const { translate } = useI18n() + + const submit = useCallback(async () => { + try { + setInputDisabled(true) + await onSubmit({ + name: value, + type: 'markdown', + children: [], + data: null, + }) + } finally { + setInputDisabled(false) + } + }, [onSubmit, value]) + + useEffectOnce(() => { + if (inputRef.current != null && !inputDisabled) { + inputRef.current.focus() + } + }) + + return ( + setValue(event.target.value), + }, + }, + ], + }, + ]} + submitButton={{ label: translate(lngKeys.GeneralCreate) }} + onSubmit={submit} + /> + ) +} + +export default MarkdownForm diff --git a/src/cloud/components/Blocks/views/Markdown.tsx b/src/cloud/components/Blocks/views/Markdown.tsx new file mode 100644 index 0000000000..46eff4a23e --- /dev/null +++ b/src/cloud/components/Blocks/views/Markdown.tsx @@ -0,0 +1,89 @@ +import React, { useState, useMemo, useCallback, useRef } from 'react' +import styled from '../../../../design/lib/styled' +import { MarkdownBlock } from '../../../api/blocks' +import CodeMirrorEditor from '../../../lib/editor/components/CodeMirrorEditor' +import { CodeMirrorKeyMap, useSettings } from '../../../lib/stores/settings' +import { ViewProps } from '../BlockContent' +import MarkdownPreview from '../../MarkdownView' + +const MarkdownView = ({ block }: ViewProps) => { + const [mode, setMode] = useState<'editor' | 'view'>() + const [editorContent, setEditorContent] = useState('') + const editorRef = useRef(null) + const { settings } = useSettings() + const editorConfig: CodeMirror.EditorConfiguration = useMemo(() => { + const editorTheme = settings['general.editorTheme'] + const theme = + editorTheme == null || editorTheme === 'default' + ? settings['general.theme'] === 'light' + ? 'default' + : 'material-darker' + : editorTheme === 'solarized-dark' + ? 'solarized dark' + : editorTheme + const keyMap = resolveKeyMap(settings['general.editorKeyMap']) + const editorIndentType = settings['general.editorIndentType'] + const editorIndentSize = settings['general.editorIndentSize'] + + return { + mode: 'markdown', + lineNumbers: true, + lineWrapping: true, + theme, + indentWithTabs: editorIndentType === 'tab', + indentUnit: editorIndentSize, + tabSize: editorIndentSize, + keyMap, + extraKeys: { + Enter: 'newlineAndIndentContinueMarkdownList', + Tab: 'indentMore', + }, + } + }, [settings]) + + const bindCallback = useCallback((editor: CodeMirror.Editor) => { + setEditorContent(editor.getValue()) + editorRef.current = editor + editorRef.current.on('blue', () => setMode('view')) + }, []) + + return ( + + +
setMode('editor')} + className='block__markdown--preview' + > + +
+
+ ) +} + +const StyledMarkdownView = styled.div` + position: relative; + & > .block__markdown--preview { + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 100%; + } + + &.block__markdown--mode-editor { + & > .block__markdown--preview { + display: none; + } + } +` + +function resolveKeyMap(keyMap: CodeMirrorKeyMap) { + switch (keyMap) { + case 'vim': + return 'vim' + case 'default': + default: + return 'sublime' + } +} +export default MarkdownView From e0a2714cba300b13cd49929b7516ff7e2b31a13d Mon Sep 17 00:00:00 2001 From: Simon Leigh Date: Tue, 3 Aug 2021 12:11:58 +0900 Subject: [PATCH 010/106] GithubIssue Block View & Form --- .../Blocks/forms/GithubIssueForm.tsx | 435 ++++++++++++++++++ .../components/Blocks/views/GithubIssue.tsx | 83 ++++ 2 files changed, 518 insertions(+) create mode 100644 src/cloud/components/Blocks/forms/GithubIssueForm.tsx create mode 100644 src/cloud/components/Blocks/views/GithubIssue.tsx diff --git a/src/cloud/components/Blocks/forms/GithubIssueForm.tsx b/src/cloud/components/Blocks/forms/GithubIssueForm.tsx new file mode 100644 index 0000000000..c8692e155e --- /dev/null +++ b/src/cloud/components/Blocks/forms/GithubIssueForm.tsx @@ -0,0 +1,435 @@ +import { mdiGithub } from '@mdi/js' +import React, { + useMemo, + useState, + useEffect, + useCallback, + useRef, + ChangeEventHandler, +} from 'react' +import { FormSelect } from '../../../../components/atoms/form' +import Button from '../../../../design/components/atoms/Button' +import Icon from '../../../../design/components/atoms/Icon' +import Spinner from '../../../../design/components/atoms/Spinner' +import FormInput from '../../../../design/components/molecules/Form/atoms/FormInput' +import FormRow from '../../../../design/components/molecules/Form/templates/FormRow' +import FormRowItem from '../../../../design/components/molecules/Form/templates/FormRowItem' +import { useTeamIntegrations } from '../../../../design/lib/stores/integrations' +import { useToast } from '../../../../design/lib/stores/toast' +import styled from '../../../../design/lib/styled' +import { GithubIssueBlock } from '../../../api/blocks' +import { getAction, IntegrationActionTypes } from '../../../api/integrations' +import { SerializedTeamIntegration } from '../../../interfaces/db/connections' +import { FormProps } from '../BlockContent' + +type State = + | { stage: 'initialising' } + | { stage: 'integrate' } + | { stage: 'issue_select'; integrations: SerializedTeamIntegration[] } + +const GithubIssueForm = ({ onSubmit }: FormProps) => { + const integrationState = useTeamIntegrations() + const [state, setState] = useState({ stage: 'initialising' }) + + useEffect(() => { + if (integrationState.type === 'initialising') { + setState({ stage: 'initialising' }) + return + } + const githubIntegrations = integrationState.integrations.filter( + (integration) => integration.service === 'github:team' + ) + + if (githubIntegrations.length === 0) { + setState({ stage: 'integrate' }) + return + } + + setState({ stage: 'issue_select', integrations: githubIntegrations }) + }, [integrationState]) + + const runImport = useCallback( + async (issues: Issue[]) => { + for (const issue of issues) { + await onSubmit({ + type: 'github.issue', + name: issue.title, + data: issue, + children: [], + }) + } + }, + [onSubmit] + ) + + return ( + + {(() => { + switch (state.stage) { + case 'initialising': + return + case 'integrate': + return
Please Integrate
+ case 'issue_select': + return ( + + ) + } + })()} +
+ ) +} + +interface GithubIssueSelectorProps { + integrations: SerializedTeamIntegration[] + onImport: (issues: Record[]) => Promise +} + +type Org = IntegrationActionTypes['orgs:list'][number] +type Repo = IntegrationActionTypes['org:repos'][number] +type Issue = IntegrationActionTypes['repo:issues'][number] + +const GithubIssueSelector = ({ + integrations, + onImport, +}: GithubIssueSelectorProps) => { + const [currentIntegration, setCurrentIntegration] = useState(integrations[0]) + const [organisations, setOrganisations] = useState([]) + const [currentOrg, setCurrentOrg] = useState(null) + const [repos, setRepos] = useState([]) + const [currentRepo, setCurrentRepo] = useState(null) + const [issues, setIssues] = useState([]) + const [selectedIssues, setSelectedIssues] = useState>(new Set()) + const [isLoading, setIsLoading] = useState(false) + const [search, setSearch] = useState('') + const { pushApiErrorMessage } = useToast() + + const errorHandleRef = useRef(pushApiErrorMessage) + useEffect(() => { + errorHandleRef.current = pushApiErrorMessage + }, [pushApiErrorMessage]) + + const integrationRef = useRef(currentIntegration) + useEffect(() => { + integrationRef.current = currentIntegration + }, [currentIntegration]) + + useEffect(() => { + setOrganisations([]) + setCurrentOrg(null) + setIsLoading(true) + getAction(currentIntegration, 'orgs:list') + .then((orgs) => { + setOrganisations(orgs) + setCurrentOrg(orgs[0] || null) + }) + .finally(() => setIsLoading(false)) + .catch(errorHandleRef.current) + }, [currentIntegration]) + + useEffect(() => { + setRepos([]) + setCurrentRepo(null) + if (currentOrg != null) { + setIsLoading(true) + getAction(integrationRef.current, 'org:repos', { org: currentOrg.login }) + .then((repos) => { + setRepos(repos) + setCurrentRepo(repos[0] || null) + }) + .finally(() => setIsLoading(false)) + .catch(errorHandleRef.current) + } + }, [currentOrg]) + + useEffect(() => { + setIssues([]) + setSelectedIssues(new Set()) + if (currentRepo != null) { + setIsLoading(true) + getAction(integrationRef.current, 'repo:issues', { + owner: currentRepo.owner.login, + repo: currentRepo.name, + }) + .then(setIssues) + .finally(() => setIsLoading(false)) + .catch(errorHandleRef.current) + setIsLoading(false) + } + }, [currentRepo]) + + const setIntegration = useCallback( + ({ value }) => { + const integration = integrations.find(({ id }) => id === value) + if (integration != null) { + setCurrentIntegration(integration) + } + }, + [integrations] + ) + + const setOrg = useCallback( + ({ value }) => { + const org = organisations.find(({ id }) => id === value) + if (org) { + setCurrentOrg(org) + } + }, + [organisations] + ) + + const setRepo = useCallback( + ({ value }) => { + const repo = repos.find(({ id }) => id === value) + if (repo) { + setCurrentRepo(repo) + } + }, + [repos] + ) + + const integrationOptions = useMemo(() => { + return integrations.map(({ id, name }) => ({ label: name, value: id })) + }, [integrations]) + + const organisationOptions = useMemo(() => { + return organisations.map(({ id, login }) => ({ label: login, value: id })) + }, [organisations]) + + const repoOptions = useMemo(() => { + return repos.map(({ id, name }) => ({ label: name, value: id })) + }, [repos]) + + const filteredIssues: Issue[] = useMemo(() => { + const lower = search.toLowerCase() + return search === '' + ? issues + : issues.filter( + (issue) => + issue.title.toLowerCase().includes(lower) || + selectedIssues.has(issue) + ) + }, [issues, search, selectedIssues]) + + const toggleAll: React.ChangeEventHandler = useCallback( + (ev) => { + if (ev.target.checked) { + setSelectedIssues(new Set(issues)) + } else { + setSelectedIssues(new Set()) + } + }, + [issues] + ) + + const createToggleSelect = useCallback( + (issue: Issue): ChangeEventHandler => { + return (ev) => { + const checked = ev.target.checked + setSelectedIssues((old) => { + if (checked) { + old.add(issue) + } else { + old.delete(issue) + } + return new Set(old) + }) + } + }, + [] + ) + const runImport = useCallback(async () => { + try { + setIsLoading(true) + await onImport(Array.from(selectedIssues.values())) + } finally { + setIsLoading(false) + } + }, [selectedIssues, onImport]) + + return ( +
+ + + + + + + + + + + + setSearch(ev.target.value)} + /> + + +
+ + + + + + + + + + + + {filteredIssues.map((issue) => ( + + + + + + + + ))} + +
+ {' '} + Title + AssigneesStatusLabelsLinkedPR
+ + {issue.title} + + {issue.assignees + .map((assignee: any) => assignee.login) + .join(', ')} + {issue.status} + {issue.labels.map((label: any) => label.name).join(', ')} + + {issue.pull_request != null && issue.pull_request.html_url} +
+
+
+ +
+
+ ) +} + +const GithubIssueFormLayout = (props: React.PropsWithChildren<{}>) => { + return ( + +

+ GitHub +

+
{props.children}
+
+ ) +} + +const StyledGithubIssueForm = styled.div` + height: 80vh; + display: flex; + flex-direction: column; + + & .github-issue__form__title { + display: flex; + align-items: center; + font-size: ${({ theme }) => theme.sizes.fonts.md}px; + margin: 0; + margin-bottom: ${({ theme }) => theme.sizes.spaces.md}px; + padding: ${({ theme }) => theme.sizes.spaces.df}px 0; + svg { + margin-right: ${({ theme }) => theme.sizes.spaces.sm}px; + } + } + + & .github-issue__form__content { + flex: 1 1 auto; + min-height: 0; + } + + & .github-issue__form__importer { + height: 100%; + display: flex; + flex-direction: column; + } + + & .github-issue__form__table { + flex-grow: 1; + overflow: auto; + } + + & .github-issue__form__action { + display: flex; + align-items: center; + justify-content: center; + padding: ${({ theme }) => theme.sizes.spaces.md}px 0; + border-top: 1px solid ${({ theme }) => theme.colors.border.second}; + } + + & table { + width: 100%; + text-align: left; + border-collapse: collapse; + margin-top: ${({ theme }) => theme.sizes.spaces.md}px; + } + + & td, + th { + border: 1px solid ${({ theme }) => theme.colors.border.main}; + padding: ${({ theme }) => theme.sizes.spaces.sm}px; + } + + & .block__table__view__import { + border-bottom: 1px solid ${({ theme }) => theme.colors.border.main}; + } +` + +export default GithubIssueForm diff --git a/src/cloud/components/Blocks/views/GithubIssue.tsx b/src/cloud/components/Blocks/views/GithubIssue.tsx new file mode 100644 index 0000000000..2e25994dce --- /dev/null +++ b/src/cloud/components/Blocks/views/GithubIssue.tsx @@ -0,0 +1,83 @@ +import React from 'react' +import { ViewProps } from '../BlockContent' +import { mdiLinkBoxOutline } from '@mdi/js' +import styled from '../../../../design/lib/styled' +import Icon from '../../../../design/components/atoms/Icon' +import { GithubIssueBlock } from '../../../api/blocks' + +const GithubIssueView = ({ block }: ViewProps) => { + return ( + +

{block.data.title}

+
+
+
Issue number
+ +
+
+
Assignees
+
+ {block.data.assignees + .map((assignee: any) => assignee.login) + .join(', ')} +
+
+
+
Status
+
{block.data.status}
+
+
+
Labels
+
+ {block.data.labels.map((label: any) => label.name).join(', ')} +
+
+ +
+
+ ) +} + +const StyledGithubIssueView = styled.div` + .github-issue__view__info { + & > div { + display: flex; + align-items: center; + padding: ${({ theme }) => theme.sizes.spaces.sm}px 0; + & > div:first-child { + width: 100px; + color: ${({ theme }) => theme.colors.text.subtle}; + } + } + + & a { + color: ${({ theme }) => theme.colors.text.primary}; + text-decoration: none; + } + } +` + +export default GithubIssueView From 1120f31ed6cbaa11ce46a9cbdefe71aa8a8a1e98 Mon Sep 17 00:00:00 2001 From: Simon Leigh Date: Tue, 3 Aug 2021 12:12:19 +0900 Subject: [PATCH 011/106] Table Block View & Form --- .../components/Blocks/forms/TableForm.tsx | 60 +++++++++++++ src/cloud/components/Blocks/views/Table.tsx | 88 +++++++++++++++++++ 2 files changed, 148 insertions(+) create mode 100644 src/cloud/components/Blocks/forms/TableForm.tsx create mode 100644 src/cloud/components/Blocks/views/Table.tsx diff --git a/src/cloud/components/Blocks/forms/TableForm.tsx b/src/cloud/components/Blocks/forms/TableForm.tsx new file mode 100644 index 0000000000..dc8fad3dfa --- /dev/null +++ b/src/cloud/components/Blocks/forms/TableForm.tsx @@ -0,0 +1,60 @@ +import React, { useRef, useState, useCallback } from 'react' +import { useEffectOnce } from 'react-use' +import Form from '../../../../design/components/molecules/Form' +import { TableBlock } from '../../../api/blocks' +import { useI18n } from '../../../lib/hooks/useI18n' +import { lngKeys } from '../../../lib/i18n/types' +import { FormProps } from '../BlockContent' + +const TableForm = ({ onSubmit }: FormProps) => { + const inputRef = useRef(null) + const [value, setValue] = useState('') + const [inputDisabled, setInputDisabled] = useState(false) + const { translate } = useI18n() + + const submit = useCallback(async () => { + try { + setInputDisabled(true) + await onSubmit({ + name: value, + type: 'table', + children: [], + data: { columns: {} }, + }) + } finally { + setInputDisabled(false) + } + }, [onSubmit, value]) + + useEffectOnce(() => { + if (inputRef.current != null && !inputDisabled) { + inputRef.current.focus() + } + }) + + return ( + setValue(event.target.value), + }, + }, + ], + }, + ]} + submitButton={{ label: translate(lngKeys.GeneralCreate) }} + onSubmit={submit} + /> + ) +} + +export default TableForm diff --git a/src/cloud/components/Blocks/views/Table.tsx b/src/cloud/components/Blocks/views/Table.tsx new file mode 100644 index 0000000000..c66ee881f7 --- /dev/null +++ b/src/cloud/components/Blocks/views/Table.tsx @@ -0,0 +1,88 @@ +import React, { useCallback } from 'react' +import { ViewProps } from '../BlockContent' +import { mdiPlus } from '@mdi/js' +import GithubIssueForm from '../forms/GithubIssueForm' +import { TableBlock } from '../../../api/blocks' +import { useModal } from '../../../../design/lib/stores/modal' +import Button from '../../../../design/components/atoms/Button' +import styled from '../../../../design/lib/styled' + +const TableView = ({ block, actions }: ViewProps) => { + const { openModal } = useModal() + + const importIssues = useCallback(() => { + openModal( + { + return actions.create(issueBlock, block) + }} + />, + { + width: 'large', + showCloseIcon: true, + } + ) + }, [openModal, actions, block]) + + return ( + + + + + + + + + + + + + {block.children.map(({ data }) => { + return ( + + + + + + + + ) + })} + +
TitleAssigneesStatusLabelsLinked PR
{data.title} + {data.assignees + .map((assignee: any) => assignee.login) + .join(', ')} + {data.state} + {data.labels.map((label: any) => label.name).join(', ')} + + {data.pull_request != null && data.pull_request.html_url} +
+
+ +
+
+ ) +} + +const StyledTableView = styled.div` + & table { + width: 100%; + text-align: left; + border-collapse: collapse; + } + + & td, + th { + border: 1px solid ${({ theme }) => theme.colors.border.main}; + padding: ${({ theme }) => theme.sizes.spaces.sm}px; + } + + & .block__table__view__import { + border-bottom: 1px solid ${({ theme }) => theme.colors.border.main}; + } +` + +export default TableView From b899076ac97360d7744050bbe18f5d26a6c6f4de Mon Sep 17 00:00:00 2001 From: Simon Leigh Date: Tue, 3 Aug 2021 12:12:40 +0900 Subject: [PATCH 012/106] Container Block View & Form --- .../components/Blocks/forms/ContainerForm.tsx | 60 +++++++ .../components/Blocks/views/Container.tsx | 160 ++++++++++++++++++ 2 files changed, 220 insertions(+) create mode 100644 src/cloud/components/Blocks/forms/ContainerForm.tsx create mode 100644 src/cloud/components/Blocks/views/Container.tsx diff --git a/src/cloud/components/Blocks/forms/ContainerForm.tsx b/src/cloud/components/Blocks/forms/ContainerForm.tsx new file mode 100644 index 0000000000..e3110ace1c --- /dev/null +++ b/src/cloud/components/Blocks/forms/ContainerForm.tsx @@ -0,0 +1,60 @@ +import React, { useRef, useState, useCallback } from 'react' +import { useI18n } from '../../../../lib/hooks/useI18n' +import { useEffectOnce } from 'react-use' +import Form from '../../../../../shared/components/molecules/Form' +import { lngKeys } from '../../../../lib/i18n/types' +import { FormProps } from '../BlockContent' +import { ContainerBlock } from '../../../../api/blocks' + +const ContainerForm = ({ onSubmit }: FormProps) => { + const inputRef = useRef(null) + const [value, setValue] = useState('') + const [inputDisabled, setInputDisabled] = useState(false) + const { translate } = useI18n() + + const submit = useCallback(async () => { + try { + setInputDisabled(true) + await onSubmit({ + name: 'container', + type: 'container', + children: [], + data: null, + }) + } finally { + setInputDisabled(false) + } + }, [onSubmit]) + + useEffectOnce(() => { + if (inputRef.current != null && !inputDisabled) { + inputRef.current.focus() + } + }) + + return ( + setValue(event.target.value), + }, + }, + ], + }, + ]} + submitButton={{ label: translate(lngKeys.GeneralCreate) }} + onSubmit={submit} + /> + ) +} + +export default ContainerForm diff --git a/src/cloud/components/Blocks/views/Container.tsx b/src/cloud/components/Blocks/views/Container.tsx new file mode 100644 index 0000000000..eafe9cd10e --- /dev/null +++ b/src/cloud/components/Blocks/views/Container.tsx @@ -0,0 +1,160 @@ +import React, { useState, useCallback, useMemo } from 'react' +import { ViewProps } from '../BlockContent' +import EmbedView from './Embed' +import MarkdownView from './Markdown' +import TableView from './Table' +import { + mdiPlusBoxOutline, + mdiFileDocumentOutline, + mdiTable, + mdiCodeTags, +} from '@mdi/js' +import MarkdownForm from '../forms/MarkdownForm' +import TableForm from '../forms/TableForm' +import EmbedForm from '../forms/EmbedForm' +import { Block, ContainerBlock } from '../../../api/blocks' +import { useModal } from '../../../../design/lib/stores/modal' +import Icon from '../../../../design/components/atoms/Icon' +import styled from '../../../../design/lib/styled' + +interface ContainerViewProps extends ViewProps { + hideAdd?: boolean +} + +const ContainerView = ({ block, actions, hideAdd }: ContainerViewProps) => { + const { openModal, closeAllModals } = useModal() + const [addSelectOpen, setAddSelectOpen] = useState(false) + + const createBlock = useCallback( + async (newBlock: Omit) => { + await actions.create(newBlock, block) + setAddSelectOpen(false) + closeAllModals() + }, + [block, actions, closeAllModals] + ) + + const modalOptions = useMemo(() => { + return { + showCloseIcon: true, + width: 'small' as const, + title: 'Create new block', + } + }, []) + + const createMarkdown = useCallback(() => { + openModal(, modalOptions) + }, [createBlock, openModal, modalOptions]) + + const createTable = useCallback(() => { + openModal(, modalOptions) + }, [createBlock, openModal, modalOptions]) + + const createEmbed = useCallback(() => { + openModal(, modalOptions) + }, [createBlock, openModal, modalOptions]) + + return ( + +
+ {block.children.map((child) => { + switch (child.type) { + case 'container': + return ( + + ) + case 'embed': + return + case 'markdown': + return + case 'table': + return + default: + return ( +
Block of type ${(child as any).type} is unsupported
+ ) + } + })} +
+ {!hideAdd && ( +
setAddSelectOpen((open) => !open)} + className='block__view__container__add' + > + + Add Block +
+ )} + {addSelectOpen && ( +
+

Select a block

+
+
+ + Markdown +
+
+ + Table +
+
+ + Embed +
+
+
+ )} +
+ ) +} + +const StyledContainerView = styled.div` + width: 100%; + + & .block__view__container__content > * { + margin-top: ${({ theme }) => theme.sizes.spaces.md}px; + } + + & .block__view__container__add { + display: flex; + justify-content: center; + cursor: pointer; + + border: 1px solid ${({ theme }) => theme.colors.border.main}; + padding: ${({ theme }) => theme.sizes.spaces.df}px; + & span { + margin-left: ${({ theme }) => theme.sizes.spaces.df}px; + } + } + + & .block__view__container__add__select { + margin: 0 auto; + margin-top: ${({ theme }) => theme.sizes.spaces.df}px; + border-radius: 2px; + background-color: ${({ theme }) => theme.colors.background.secondary}; + padding: ${({ theme }) => theme.sizes.spaces.df}px 0; + width: 60%; + & > h3 { + text-align: center; + margin-top: 0; + } + & > div { + display: flex; + justify-content: space-evenly; + & .block__view__add__selector { + cursor: pointer; + display: flex; + flex-direction: column; + align-items: center; + span { + margin-top: ${({ theme }) => theme.sizes.spaces.sm}px; + } + } + } + } +` + +export default ContainerView From 744036439a37d5c7d95126167466be0745eb8d1a Mon Sep 17 00:00:00 2001 From: Simon Leigh Date: Tue, 3 Aug 2021 12:12:59 +0900 Subject: [PATCH 013/106] Block Tree Nav --- src/cloud/components/Blocks/BlockTree.tsx | 121 ++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 src/cloud/components/Blocks/BlockTree.tsx diff --git a/src/cloud/components/Blocks/BlockTree.tsx b/src/cloud/components/Blocks/BlockTree.tsx new file mode 100644 index 0000000000..8d61861e4a --- /dev/null +++ b/src/cloud/components/Blocks/BlockTree.tsx @@ -0,0 +1,121 @@ +import React, { useMemo } from 'react' +import { + mdiCodeTags, + mdiTable, + mdiFileDocumentOutline, + mdiGithub, + mdiPackageVariantClosed, + mdiTrashCanOutline, +} from '@mdi/js' +import { Block } from '../../api/blocks' +import styled from '../../../design/lib/styled' +import { capitalize } from '../../lib/utils/string' +import Icon, { IconSize } from '../../../design/components/atoms/Icon' + +interface BlockTreeProps { + root: Block + onSelect: (block: Block) => void + onDelete: (block: Block) => void + depth?: number +} + +const BlockTree = ({ root, onSelect, onDelete, depth }: BlockTreeProps) => { + return ( + +
+ + onSelect(root)}>{capitalize(root.type)} + onDelete(root)}> + + +
+
+ {root.children.length > 0 && + (root.children as Block[]).map((child: Block) => ( + + ))} +
+
+ ) +} + +const StyledBlockTree = styled.div<{ depth: number }>` + font-size: ${({ theme }) => theme.sizes.fonts.df}px; + + & > .block__tree__label { + padding-left: ${({ depth }) => 18 + (depth as number) * 15}px; + display: flex; + width: 100%; + height: 26px; + white-space: nowrap; + font-size: ${({ theme }) => theme.sizes.fonts.df}px; + cursor: pointer; + + align-items: center; + flex: 1 1 auto; + background: none; + outline: 0; + border: 0; + text-align: left; + color: ${({ theme }) => theme.colors.text.secondary}; + text-decoration: none; + margin: 0; + overflow: hidden; + & > span:first-of-type { + flex-grow: 1; + } + & svg { + color: ${({ theme }) => theme.colors.text.subtle}; + margin-right: ${({ theme }) => theme.sizes.spaces.sm}px; + } + + & .block__tree__action { + display: none; + } + + &:hover { + background-color: ${({ theme }) => theme.colors.background.secondary}; + + & .block__tree__action { + display: block; + } + } + } +` + +interface BlockIconProps { + block: Block + size: IconSize +} + +const BlockIcon = ({ block, size }: BlockIconProps) => { + const path = useMemo(() => { + if (block.type.startsWith('github')) { + return mdiGithub + } + switch (block.type) { + case 'embed': + return mdiCodeTags + case 'table': + return mdiTable + case 'markdown': + return mdiFileDocumentOutline + default: + return mdiPackageVariantClosed + } + }, [block.type]) + + return +} + +export default BlockTree From 73ebf3bac4e6260305244d4800f05561f54aa294 Mon Sep 17 00:00:00 2001 From: Simon Leigh Date: Tue, 3 Aug 2021 12:13:20 +0900 Subject: [PATCH 014/106] Block Editor --- src/cloud/components/Blocks/BlockContent.tsx | 270 ++++++++++++++---- src/cloud/components/Blocks/BlockEditor.tsx | 19 ++ .../components/organisms/DocPage/Edit.tsx | 62 ++++ 3 files changed, 296 insertions(+), 55 deletions(-) create mode 100644 src/cloud/components/Blocks/BlockEditor.tsx create mode 100644 src/cloud/components/organisms/DocPage/Edit.tsx diff --git a/src/cloud/components/Blocks/BlockContent.tsx b/src/cloud/components/Blocks/BlockContent.tsx index 58dfefb9cb..e701777666 100644 --- a/src/cloud/components/Blocks/BlockContent.tsx +++ b/src/cloud/components/Blocks/BlockContent.tsx @@ -1,73 +1,233 @@ -import React, { useCallback } from 'react' -import { Block } from '../../api/blocks' -import { SerializedDoc } from '../../interfaces/db/doc' -import { useDocBlocks } from '../../lib/hooks/useDocBlocks' - -const BlockContent = (doc: SerializedDoc) => { - const { state, actions } = useDocBlocks(doc.id) - - const createContainer = useCallback(async () => { - if (doc.rootBlock != null) { - await actions.create( - { - name: 'container', - type: 'container', - children: [], - data: null, - }, - doc.rootBlock - ) +import React, { useCallback, useState, useMemo } from 'react' +import { + mdiPlus, + mdiChevronDown, + mdiPackageVariantClosed, + mdiCodeTags, + mdiTable, + mdiFileDocumentOutline, + mdiChevronLeft, +} from '@mdi/js' +import MarkdownForm from './forms/MarkdownForm' +import EmbedForm from './forms/EmbedForm' +import TableForm from './forms/TableForm' +import TableView from './views/Table' +import MarkdownView from './views/Markdown' +import EmbedView from './views/Embed' +import ContainerView from './views/Container' +import GithubIssueView from './views/GithubIssue' +import { Block, ContainerBlock } from '../../api/blocks' +import { BlockActions, useDocBlocks } from '../../lib/hooks/useDocBlocks' +import { SerializedDocWithBookmark } from '../../interfaces/db/doc' +import { useModal } from '../../../design/lib/stores/modal' +import { useI18n } from '../../lib/hooks/useI18n' +import { lngKeys } from '../../lib/i18n/types' +import ContainerForm from './forms/ContainerForm' +import BlockTree from './BlockTree' +import Icon from '../../../design/components/atoms/Icon' +import styled from '../../../design/lib/styled' + +export interface ViewProps { + block: T + actions: BlockActions +} + +export interface FormProps { + onSubmit: (block: Omit) => Promise +} + +interface BlockContentProps { + doc: SerializedDocWithBookmark & { rootBlock: ContainerBlock } +} + +const BlockContent = ({ doc }: BlockContentProps) => { + const { state, actions } = useDocBlocks(doc.rootBlock.id) + const { openModal, closeAllModals } = useModal() + const { translate } = useI18n() + const [currentBlock, setCurrentBlock] = useState(null) + const [showActions, setShowActions] = useState(true) + + const createBlock = useCallback( + async (block: Omit) => { + await actions.create(block, doc.rootBlock) + closeAllModals() + }, + [doc, actions, closeAllModals] + ) + + const modalOptions = useMemo(() => { + return { + showCloseIcon: true, + title: translate(lngKeys.ModalsCreateNewDocument), } - }, [doc, actions]) + }, [translate]) + + const createContainer = useCallback(() => { + openModal(, modalOptions) + }, [createBlock, openModal, modalOptions]) + + const createMarkdown = useCallback(() => { + openModal(, modalOptions) + }, [createBlock, openModal, modalOptions]) + + const createTable = useCallback(() => { + openModal(, modalOptions) + }, [createBlock, openModal, modalOptions]) + + const createEmbed = useCallback(() => { + openModal(, modalOptions) + }, [createBlock, openModal, modalOptions]) + + const content = useMemo(() => { + if (state.type === 'loading') { + return null + } + const active = currentBlock || state.block + switch (active.type) { + case 'container': + return + case 'embed': + return + case 'markdown': + return + case 'table': + return + case 'github.issue': + return + default: + return
Block of type ${(active as any).type} is unsupported
+ } + }, [currentBlock, state, actions]) if (state.type === 'loading') { return
loading
} return ( -
-
- -
-
New Items
-
Container
-
Table
-
Embed
+ +
+ +
+
setShowActions((state) => !state)} + > + New Items + +
+ {showActions && ( +
    +
  • + + Container + +
  • +
  • + + Markdown + +
  • +
  • + + Table + +
  • +
  • + + Embed + +
  • +
+ )}
-
- -
Add Block
+
+

{doc.title}

+ {content}
-
+
) } -interface BlockTreeProps { - blocks: Block[] -} +const StyledBlockContent = styled.div` + display: flex; + height: 100%; -const BlockTree = ({ blocks }: BlockTreeProps) => { - return ( -
- {blocks.map((block) => { - return ( -
-
{capitalize(block.type)}
- {block.children.length > 0 && } -
- ) - })} -
- ) -} + & > .block__editor__nav { + padding-top: ${({ theme }) => theme.sizes.spaces.df}px; + display: flex; + flex-direction: column; + border: 1px solid ${({ theme }) => theme.colors.border.main}; + width: 240px; + flex: 0 0 auto; -interface BlockViewProps { - blocks: Block[] -} + & > div:first-child { + flex-grow: 1; + } -const BlockView = ({ blocks }: BlockViewProps) => { - return
{JSON.stringify(blocks)}
-} + & .block__editor__nav--item { + display: flex; + width: 100%; + height: 26px; + white-space: nowrap; + font-size: ${({ theme }) => theme.sizes.fonts.df}px; + cursor: pointer; + padding: 0 ${({ theme }) => theme.sizes.spaces.df}px; + + align-items: center; + flex: 1 1 auto; + background: none; + outline: 0; + border: 0; + text-align: left; + color: ${({ theme }) => theme.colors.text.secondary}; + text-decoration: none; + margin: 0; + overflow: hidden; + svg { + color: ${({ theme }) => theme.colors.text.subtle}; + } + span { + flex: 1 0 auto; + } + + &:hover { + background-color: ${({ theme }) => theme.colors.background.secondary}; + } + } + } + + & .block__editor__nav--actions { + & > ul { + list-style: none; + padding: 0; + margin: 0; + & > li { + & span { + margin-left: ${({ theme }) => theme.sizes.spaces.sm}px; + flex: 1 0 auto; + } + } + } + } + + & .block__editor__view { + flex: 1 1 auto; + height: 100%; + overflow: auto; + padding: ${({ theme }) => theme.sizes.spaces.df}px + ${({ theme }) => theme.sizes.spaces.xl}px; + } +` export default BlockContent diff --git a/src/cloud/components/Blocks/BlockEditor.tsx b/src/cloud/components/Blocks/BlockEditor.tsx new file mode 100644 index 0000000000..ad3ba36d8c --- /dev/null +++ b/src/cloud/components/Blocks/BlockEditor.tsx @@ -0,0 +1,19 @@ +import React from 'react' +import Application from '../../Application' +import { SerializedDocWithBookmark } from '../../../interfaces/db/doc' +import BlockContent from './BlockContent' +import { Block } from '../../../api/blocks' + +interface BlockEditorProps { + doc: SerializedDocWithBookmark & { rootBlock: Block } +} + +const BlockEditor = ({ doc }: BlockEditorProps) => { + return ( + + + + ) +} + +export default BlockEditor diff --git a/src/cloud/components/organisms/DocPage/Edit.tsx b/src/cloud/components/organisms/DocPage/Edit.tsx new file mode 100644 index 0000000000..e90814f043 --- /dev/null +++ b/src/cloud/components/organisms/DocPage/Edit.tsx @@ -0,0 +1,62 @@ +import React from 'react' +import { + SerializedDocWithBookmark, + SerializedDoc, +} from '../../../interfaces/db/doc' +import Editor from '../../molecules/Editor' +import { SerializedTeam } from '../../../interfaces/db/team' +import { SerializedUser } from '../../../interfaces/db/user' +import styled from '../../../lib/styled' +import { rightSideTopBarHeight } from '../RightSideTopBar/styled' +import { SerializedRevision } from '../../../interfaces/db/revision' +import BlockEditor from '../Blocks/BlockEditor' + +interface EditPageProps { + doc: SerializedDocWithBookmark + team: SerializedTeam + user: SerializedUser + contributors: SerializedUser[] + revisionHistory: SerializedRevision[] + backLinks: SerializedDoc[] +} + +const EditPage = ({ + doc, + team, + user, + contributors, + revisionHistory, + backLinks, +}: EditPageProps) => { + return ( + + {doc.rootBlock == null ? ( + + ) : ( + + )} + + ) +} + +const StyledDocEditPage = styled.div` + margin: 0; + padding: 0; + padding-top: ${rightSideTopBarHeight}px; + min-height: calc(100vh - ${rightSideTopBarHeight}px); + height: auto; + display: flex; + + .cm-link { + text-decoration: none; + } +` + +export default EditPage From 84bbba9a0c5d3f52ab8382d55caaa3a80e494514 Mon Sep 17 00:00:00 2001 From: Simon Leigh Date: Tue, 3 Aug 2021 12:13:29 +0900 Subject: [PATCH 015/106] bug fix --- src/cloud/lib/editor/hooks/useRealtime.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/cloud/lib/editor/hooks/useRealtime.ts b/src/cloud/lib/editor/hooks/useRealtime.ts index 2bfa89f96d..6b0b4f909b 100644 --- a/src/cloud/lib/editor/hooks/useRealtime.ts +++ b/src/cloud/lib/editor/hooks/useRealtime.ts @@ -11,7 +11,7 @@ export type PresenceChange = | { type: 'disconnected'; sessionId: number } export interface RealtimeArgs { - token: string + token?: string id: string userInfo?: T } @@ -79,7 +79,9 @@ const useRealtime = ({ }) .catch((error) => console.error(error)) - const provider = new WebsocketProvider('', token, doc, { + // TODO: Get Collaboration Token + const authToken = token != null ? token : '' + const provider = new WebsocketProvider('', authToken, doc, { WebSocketPolyfill: constructor, resyncInterval: 10000, }) From 8362a2163cf54667dd5d36c89d74fd8699856250 Mon Sep 17 00:00:00 2001 From: Simon Leigh Date: Wed, 4 Aug 2021 09:56:14 +0900 Subject: [PATCH 016/106] Remove always displayed canvas title --- src/cloud/components/Blocks/BlockContent.tsx | 26 +++++----- .../components/Blocks/views/Container.tsx | 49 +++++++++++++++---- 2 files changed, 53 insertions(+), 22 deletions(-) diff --git a/src/cloud/components/Blocks/BlockContent.tsx b/src/cloud/components/Blocks/BlockContent.tsx index e701777666..d24c2bc62c 100644 --- a/src/cloud/components/Blocks/BlockContent.tsx +++ b/src/cloud/components/Blocks/BlockContent.tsx @@ -27,9 +27,14 @@ import BlockTree from './BlockTree' import Icon from '../../../design/components/atoms/Icon' import styled from '../../../design/lib/styled' +interface Canvas extends SerializedDocWithBookmark { + rootBlock: ContainerBlock +} + export interface ViewProps { block: T actions: BlockActions + canvas: Canvas } export interface FormProps { @@ -37,7 +42,7 @@ export interface FormProps { } interface BlockContentProps { - doc: SerializedDocWithBookmark & { rootBlock: ContainerBlock } + doc: Canvas } const BlockContent = ({ doc }: BlockContentProps) => { @@ -85,19 +90,19 @@ const BlockContent = ({ doc }: BlockContentProps) => { const active = currentBlock || state.block switch (active.type) { case 'container': - return + return case 'embed': - return + return case 'markdown': - return + return case 'table': - return + return case 'github.issue': - return + return default: return
Block of type ${(active as any).type} is unsupported
} - }, [currentBlock, state, actions]) + }, [currentBlock, state, actions, doc]) if (state.type === 'loading') { return
loading
@@ -151,10 +156,7 @@ const BlockContent = ({ doc }: BlockContentProps) => { )}
-
-

{doc.title}

- {content} -
+
{content}
) } @@ -225,8 +227,6 @@ const StyledBlockContent = styled.div` flex: 1 1 auto; height: 100%; overflow: auto; - padding: ${({ theme }) => theme.sizes.spaces.df}px - ${({ theme }) => theme.sizes.spaces.xl}px; } ` diff --git a/src/cloud/components/Blocks/views/Container.tsx b/src/cloud/components/Blocks/views/Container.tsx index eafe9cd10e..06fcf361b1 100644 --- a/src/cloud/components/Blocks/views/Container.tsx +++ b/src/cloud/components/Blocks/views/Container.tsx @@ -18,10 +18,15 @@ import Icon from '../../../../design/components/atoms/Icon' import styled from '../../../../design/lib/styled' interface ContainerViewProps extends ViewProps { - hideAdd?: boolean + nested?: boolean } -const ContainerView = ({ block, actions, hideAdd }: ContainerViewProps) => { +const ContainerView = ({ + block, + actions, + canvas, + nested, +}: ContainerViewProps) => { const { openModal, closeAllModals } = useModal() const [addSelectOpen, setAddSelectOpen] = useState(false) @@ -55,20 +60,32 @@ const ContainerView = ({ block, actions, hideAdd }: ContainerViewProps) => { }, [createBlock, openModal, modalOptions]) return ( - + +

{canvas.rootBlock.id === block.id ? canvas.title : block.name}

{block.children.map((child) => { switch (child.type) { case 'container': return ( - + ) case 'embed': - return + return ( + + ) case 'markdown': - return + return ( + + ) case 'table': - return + return ( + + ) default: return (
Block of type ${(child as any).type} is unsupported
@@ -76,7 +93,7 @@ const ContainerView = ({ block, actions, hideAdd }: ContainerViewProps) => { } })}
- {!hideAdd && ( + {!nested && (
setAddSelectOpen((open) => !open)} className='block__view__container__add' @@ -113,9 +130,23 @@ const ContainerView = ({ block, actions, hideAdd }: ContainerViewProps) => { const StyledContainerView = styled.div` width: 100%; + padding: ${({ theme }) => theme.sizes.spaces.df}px + ${({ theme }) => theme.sizes.spaces.xl}px; + + & > & h1 { + font-size: 5px; + } + + &.block__view--nested { + padding: 0; + & h1 { + font-size: ${({ theme }) => theme.sizes.fonts.md}px; + color: ${({ theme }) => theme.colors.text.subtle}; + } + } & .block__view__container__content > * { - margin-top: ${({ theme }) => theme.sizes.spaces.md}px; + margin-bottom: ${({ theme }) => theme.sizes.spaces.md}px; } & .block__view__container__add { From 4f0d8af33574b8749d15ccf60a17716b39316e0a Mon Sep 17 00:00:00 2001 From: Simon Leigh Date: Wed, 4 Aug 2021 12:16:17 +0900 Subject: [PATCH 017/106] full screen embed --- src/cloud/components/Blocks/BlockContent.tsx | 2 +- .../components/Blocks/views/Container.tsx | 5 ++- src/cloud/components/Blocks/views/Embed.tsx | 7 ++-- src/design/components/atoms/AspectRation.tsx | 34 +++++++++++++++++++ 4 files changed, 41 insertions(+), 7 deletions(-) create mode 100644 src/design/components/atoms/AspectRation.tsx diff --git a/src/cloud/components/Blocks/BlockContent.tsx b/src/cloud/components/Blocks/BlockContent.tsx index d24c2bc62c..f03aa6454b 100644 --- a/src/cloud/components/Blocks/BlockContent.tsx +++ b/src/cloud/components/Blocks/BlockContent.tsx @@ -42,7 +42,7 @@ export interface FormProps { } interface BlockContentProps { - doc: Canvas + doc: Canvas } const BlockContent = ({ doc }: BlockContentProps) => { diff --git a/src/cloud/components/Blocks/views/Container.tsx b/src/cloud/components/Blocks/views/Container.tsx index 06fcf361b1..abdadad0d0 100644 --- a/src/cloud/components/Blocks/views/Container.tsx +++ b/src/cloud/components/Blocks/views/Container.tsx @@ -16,6 +16,7 @@ import { Block, ContainerBlock } from '../../../api/blocks' import { useModal } from '../../../../design/lib/stores/modal' import Icon from '../../../../design/components/atoms/Icon' import styled from '../../../../design/lib/styled' +import AspectRatio from '../../../../design/components/atoms/AspectRation' interface ContainerViewProps extends ViewProps { nested?: boolean @@ -76,7 +77,9 @@ const ContainerView = ({ ) case 'embed': return ( - + + + ) case 'markdown': return ( diff --git a/src/cloud/components/Blocks/views/Embed.tsx b/src/cloud/components/Blocks/views/Embed.tsx index 81541919c8..02ba8cb91d 100644 --- a/src/cloud/components/Blocks/views/Embed.tsx +++ b/src/cloud/components/Blocks/views/Embed.tsx @@ -13,13 +13,10 @@ const EmbedView = ({ block }: ViewProps) => { const StyledEmbedView = styled.div` width: 100%; - position: relative; - padding-top: 56.25%; + height: 100%; + overflow: hidden; & iframe { - position: absolute; - top: 0; - left: 0; height: 100%; width: 100%; } diff --git a/src/design/components/atoms/AspectRation.tsx b/src/design/components/atoms/AspectRation.tsx new file mode 100644 index 0000000000..18b8d000d9 --- /dev/null +++ b/src/design/components/atoms/AspectRation.tsx @@ -0,0 +1,34 @@ +import React from 'react' +import styled from '../../lib/styled' + +interface AspectRationProps { + height: number + width: number +} + +const AspectRatio = ({ + height, + width, + children, +}: React.PropsWithChildren) => { + return ( + +
{children}
+
+ ) +} + +const Container = styled.div` + width: 100%; + position: relative; + + & > div { + position: absolute; + top: 0; + left: 0; + height: 100%; + width: 100%; + } +` + +export default AspectRatio From 70a4393ed8ae6472fd6bb30a82a661d949778caa Mon Sep 17 00:00:00 2001 From: Simon Leigh Date: Wed, 4 Aug 2021 16:51:03 +0900 Subject: [PATCH 018/106] \update current block correctly --- src/cloud/components/Blocks/BlockContent.tsx | 14 +++++++++++++- src/cloud/components/Blocks/BlockTree.tsx | 20 ++++++++++++++++++-- src/design/lib/utils/tree.ts | 19 +++++++++++++++++++ 3 files changed, 50 insertions(+), 3 deletions(-) create mode 100644 src/design/lib/utils/tree.ts diff --git a/src/cloud/components/Blocks/BlockContent.tsx b/src/cloud/components/Blocks/BlockContent.tsx index f03aa6454b..1531316f4a 100644 --- a/src/cloud/components/Blocks/BlockContent.tsx +++ b/src/cloud/components/Blocks/BlockContent.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useState, useMemo } from 'react' +import React, { useCallback, useState, useMemo, useEffect } from 'react' import { mdiPlus, mdiChevronDown, @@ -26,6 +26,7 @@ import ContainerForm from './forms/ContainerForm' import BlockTree from './BlockTree' import Icon from '../../../design/components/atoms/Icon' import styled from '../../../design/lib/styled' +import { find } from '../../../design/lib/utils/tree' interface Canvas extends SerializedDocWithBookmark { rootBlock: ContainerBlock @@ -60,6 +61,16 @@ const BlockContent = ({ doc }: BlockContentProps) => { [doc, actions, closeAllModals] ) + useEffect(() => { + if (state.type === 'loaded') { + setCurrentBlock((prev) => { + return prev != null + ? find(state.block, (block) => block.id === prev.id) + : null + }) + } + }, [state]) + const modalOptions = useMemo(() => { return { showCloseIcon: true, @@ -113,6 +124,7 @@ const BlockContent = ({ doc }: BlockContentProps) => {
diff --git a/src/cloud/components/Blocks/BlockTree.tsx b/src/cloud/components/Blocks/BlockTree.tsx index 8d61861e4a..196898db91 100644 --- a/src/cloud/components/Blocks/BlockTree.tsx +++ b/src/cloud/components/Blocks/BlockTree.tsx @@ -16,12 +16,23 @@ interface BlockTreeProps { root: Block onSelect: (block: Block) => void onDelete: (block: Block) => void + active?: Block depth?: number } -const BlockTree = ({ root, onSelect, onDelete, depth }: BlockTreeProps) => { +const BlockTree = ({ + root, + onSelect, + onDelete, + depth, + active, +}: BlockTreeProps) => { return ( - +
onSelect(root)}>{capitalize(root.type)} @@ -41,6 +52,7 @@ const BlockTree = ({ root, onSelect, onDelete, depth }: BlockTreeProps) => { root={child} onSelect={onSelect} onDelete={onDelete} + active={active} depth={(depth || 0) + 1} /> ))} @@ -52,6 +64,10 @@ const BlockTree = ({ root, onSelect, onDelete, depth }: BlockTreeProps) => { const StyledBlockTree = styled.div<{ depth: number }>` font-size: ${({ theme }) => theme.sizes.fonts.df}px; + &.block__tree--active > .block__tree__label { + background-color: ${({ theme }) => theme.colors.background.secondary}; + } + & > .block__tree__label { padding-left: ${({ depth }) => 18 + (depth as number) * 15}px; display: flex; diff --git a/src/design/lib/utils/tree.ts b/src/design/lib/utils/tree.ts new file mode 100644 index 0000000000..0b190f6963 --- /dev/null +++ b/src/design/lib/utils/tree.ts @@ -0,0 +1,19 @@ +type Node = T & { children: Node[] } + +export function find>( + node: Node, + cmp: (node: Node) => boolean +): Node | null { + if (cmp(node)) { + return node + } + + for (const child of node.children) { + const found = find(child, cmp) + if (found != null) { + return found + } + } + + return null +} From 45dd6d7ec64eba470dde54ace39426c9421d3bf8 Mon Sep 17 00:00:00 2001 From: Simon Leigh Date: Fri, 6 Aug 2021 10:36:33 +0900 Subject: [PATCH 019/106] Markdown block improvements --- src/cloud/components/Blocks/BlockContent.tsx | 54 +++++++++-- .../components/Blocks/views/Container.tsx | 23 ++++- .../components/Blocks/views/Markdown.tsx | 94 +++++++++++++++---- src/cloud/lib/editor/hooks/useRealtime.ts | 6 +- 4 files changed, 147 insertions(+), 30 deletions(-) diff --git a/src/cloud/components/Blocks/BlockContent.tsx b/src/cloud/components/Blocks/BlockContent.tsx index 1531316f4a..e1cef6abc1 100644 --- a/src/cloud/components/Blocks/BlockContent.tsx +++ b/src/cloud/components/Blocks/BlockContent.tsx @@ -27,6 +27,8 @@ import BlockTree from './BlockTree' import Icon from '../../../design/components/atoms/Icon' import styled from '../../../design/lib/styled' import { find } from '../../../design/lib/utils/tree' +import { WebsocketProvider } from 'y-websocket' +import useRealtime from '../../lib/editor/hooks/useRealtime' interface Canvas extends SerializedDocWithBookmark { rootBlock: ContainerBlock @@ -36,6 +38,7 @@ export interface ViewProps { block: T actions: BlockActions canvas: Canvas + realtime: WebsocketProvider } export interface FormProps { @@ -52,6 +55,10 @@ const BlockContent = ({ doc }: BlockContentProps) => { const { translate } = useI18n() const [currentBlock, setCurrentBlock] = useState(null) const [showActions, setShowActions] = useState(true) + const [provider] = useRealtime({ + token: doc.collaborationToken || '', + id: doc.id, + }) const createBlock = useCallback( async (block: Omit) => { @@ -101,19 +108,54 @@ const BlockContent = ({ doc }: BlockContentProps) => { const active = currentBlock || state.block switch (active.type) { case 'container': - return + return ( + + ) case 'embed': - return + return ( + + ) case 'markdown': - return + return ( + + ) case 'table': - return + return ( + + ) case 'github.issue': - return + return ( + + ) default: return
Block of type ${(active as any).type} is unsupported
} - }, [currentBlock, state, actions, doc]) + }, [currentBlock, state, actions, doc, provider]) if (state.type === 'loading') { return
loading
diff --git a/src/cloud/components/Blocks/views/Container.tsx b/src/cloud/components/Blocks/views/Container.tsx index abdadad0d0..eaf6d51c4d 100644 --- a/src/cloud/components/Blocks/views/Container.tsx +++ b/src/cloud/components/Blocks/views/Container.tsx @@ -27,6 +27,7 @@ const ContainerView = ({ actions, canvas, nested, + realtime, }: ContainerViewProps) => { const { openModal, closeAllModals } = useModal() const [addSelectOpen, setAddSelectOpen] = useState(false) @@ -73,21 +74,37 @@ const ContainerView = ({ actions={actions} nested={true} canvas={canvas} + realtime={realtime} /> ) case 'embed': return ( - + ) case 'markdown': return ( - + ) case 'table': return ( - + ) default: return ( diff --git a/src/cloud/components/Blocks/views/Markdown.tsx b/src/cloud/components/Blocks/views/Markdown.tsx index 46eff4a23e..282e36d4f4 100644 --- a/src/cloud/components/Blocks/views/Markdown.tsx +++ b/src/cloud/components/Blocks/views/Markdown.tsx @@ -1,14 +1,19 @@ -import React, { useState, useMemo, useCallback, useRef } from 'react' +import React, { useState, useMemo, useCallback, useRef, useEffect } from 'react' import styled from '../../../../design/lib/styled' import { MarkdownBlock } from '../../../api/blocks' import CodeMirrorEditor from '../../../lib/editor/components/CodeMirrorEditor' import { CodeMirrorKeyMap, useSettings } from '../../../lib/stores/settings' import { ViewProps } from '../BlockContent' import MarkdownPreview from '../../MarkdownView' +import { CodemirrorBinding } from 'y-codemirror' +import Spinner from '../../../../design/components/atoms/Spinner' +import Icon from '../../../../design/components/atoms/Icon' +import { mdiEyeOutline, mdiPencil } from '@mdi/js' -const MarkdownView = ({ block }: ViewProps) => { - const [mode, setMode] = useState<'editor' | 'view'>() +const MarkdownView = ({ block, realtime }: ViewProps) => { + const [mode, setMode] = useState<'editor' | 'view'>('view') const [editorContent, setEditorContent] = useState('') + const [synced, setSynced] = useState(realtime.synced) const editorRef = useRef(null) const { settings } = useSettings() const editorConfig: CodeMirror.EditorConfiguration = useMemo(() => { @@ -41,33 +46,82 @@ const MarkdownView = ({ block }: ViewProps) => { } }, [settings]) - const bindCallback = useCallback((editor: CodeMirror.Editor) => { - setEditorContent(editor.getValue()) - editorRef.current = editor - editorRef.current.on('blue', () => setMode('view')) + useEffect(() => { + realtime.on('sync', setSynced) + return () => realtime.off('sync', setSynced) + }, [realtime]) + + const bindCallback = useCallback( + (editor: CodeMirror.Editor) => { + setEditorContent(editor.getValue()) + editorRef.current = editor + editor.on('change', (instance) => { + setEditorContent(instance.getValue()) + }) + editor.setValue('') + new CodemirrorBinding( + realtime.doc.getText(block.id), + editorRef.current, + realtime.awareness + ) + editorRef.current.clearHistory() + }, + [block.id, realtime] + ) + + const toggleMode = useCallback(() => { + setMode((mode) => (mode === 'view' ? 'editor' : 'view')) + editorRef.current && editorRef.current.refresh() }, []) + if (!synced) { + return ( + + + + ) + } + return ( - -
setMode('editor')} - className='block__markdown--preview' - > - +
+
+
+ +
+ ) } const StyledMarkdownView = styled.div` position: relative; - & > .block__markdown--preview { + min-height: 20px; + + &:hover .block__markdown--toolbar { + display: block; + } + + & .block__markdown--toolbar { + display: none; position: absolute; - left: 0; + right: 0; top: 0; - width: 100%; - height: 100%; + z-index: 1000; + padding: ${({ theme }) => theme.sizes.spaces.sm}px; + + & svg { + cursor: pointer; + } + } + + & > .block__markdown--preview { + background-color: ${({ theme }) => theme.colors.background.primary}; + padding: 0; } &.block__markdown--mode-editor { @@ -75,6 +129,12 @@ const StyledMarkdownView = styled.div` display: none; } } + + &.block__markdown--mode-view { + & > .block__markdown--editor { + display: none; + } + } ` function resolveKeyMap(keyMap: CodeMirrorKeyMap) { diff --git a/src/cloud/lib/editor/hooks/useRealtime.ts b/src/cloud/lib/editor/hooks/useRealtime.ts index 6b0b4f909b..2bfa89f96d 100644 --- a/src/cloud/lib/editor/hooks/useRealtime.ts +++ b/src/cloud/lib/editor/hooks/useRealtime.ts @@ -11,7 +11,7 @@ export type PresenceChange = | { type: 'disconnected'; sessionId: number } export interface RealtimeArgs { - token?: string + token: string id: string userInfo?: T } @@ -79,9 +79,7 @@ const useRealtime = ({ }) .catch((error) => console.error(error)) - // TODO: Get Collaboration Token - const authToken = token != null ? token : '' - const provider = new WebsocketProvider('', authToken, doc, { + const provider = new WebsocketProvider('', token, doc, { WebSocketPolyfill: constructor, resyncInterval: 10000, }) From d3f133370d5b8783baadfc1820b520a629518238 Mon Sep 17 00:00:00 2001 From: Simon Leigh Date: Tue, 10 Aug 2021 09:31:37 +0900 Subject: [PATCH 020/106] in flow integration --- src/cloud/components/Blocks/BlockTree.tsx | 38 +++++++- .../Blocks/forms/GithubIssueForm.tsx | 88 ++++++++++++++++++- src/design/lib/stores/integrations/index.ts | 2 +- 3 files changed, 124 insertions(+), 4 deletions(-) diff --git a/src/cloud/components/Blocks/BlockTree.tsx b/src/cloud/components/Blocks/BlockTree.tsx index 196898db91..d221fd8165 100644 --- a/src/cloud/components/Blocks/BlockTree.tsx +++ b/src/cloud/components/Blocks/BlockTree.tsx @@ -35,7 +35,7 @@ const BlockTree = ({ >
- onSelect(root)}>{capitalize(root.type)} + onSelect(root)}>{blockTitle(root)} onDelete(root)}> ` font-size: ${({ theme }) => theme.sizes.fonts.df}px; + position: relative; &.block__tree--active > .block__tree__label { background-color: ${({ theme }) => theme.colors.background.secondary}; } & > .block__tree__label { + position: relative; padding-left: ${({ depth }) => 18 + (depth as number) * 15}px; display: flex; width: 100%; @@ -87,9 +100,11 @@ const StyledBlockTree = styled.div<{ depth: number }>` text-decoration: none; margin: 0; overflow: hidden; + & > span:first-of-type { flex-grow: 1; } + & svg { color: ${({ theme }) => theme.colors.text.subtle}; margin-right: ${({ theme }) => theme.sizes.spaces.sm}px; @@ -106,6 +121,27 @@ const StyledBlockTree = styled.div<{ depth: number }>` display: block; } } + + &:before { + content: ''; + position: absolute; + width: 8px; + top: 0; + left: ${({ depth }) => 5 + (depth as number) * 15}px; + border-bottom: 1px solid ${({ theme }) => theme.colors.border.second}; + height: 50%; + } + } + & > .block__tree__children { + position: relative; + &:before { + content: ''; + position: absolute; + border-left: 1px solid ${({ theme }) => theme.colors.border.second}; + top: 0; + left: ${({ depth }) => 5 + (depth as number) * 15}px; + height: calc(100% - 13px); + } } ` diff --git a/src/cloud/components/Blocks/forms/GithubIssueForm.tsx b/src/cloud/components/Blocks/forms/GithubIssueForm.tsx index c8692e155e..3d148eac33 100644 --- a/src/cloud/components/Blocks/forms/GithubIssueForm.tsx +++ b/src/cloud/components/Blocks/forms/GithubIssueForm.tsx @@ -1,4 +1,4 @@ -import { mdiGithub } from '@mdi/js' +import { mdiGithub, mdiPlus } from '@mdi/js' import React, { useMemo, useState, @@ -20,6 +20,8 @@ import styled from '../../../../design/lib/styled' import { GithubIssueBlock } from '../../../api/blocks' import { getAction, IntegrationActionTypes } from '../../../api/integrations' import { SerializedTeamIntegration } from '../../../interfaces/db/connections' +import { usePage } from '../../../lib/stores/pageStore' +import ServiceConnect, { Integration } from '../../ServiceConnect' import { FormProps } from '../BlockContent' type State = @@ -29,6 +31,7 @@ type State = const GithubIssueForm = ({ onSubmit }: FormProps) => { const integrationState = useTeamIntegrations() + const { team } = usePage() const [state, setState] = useState({ stage: 'initialising' }) useEffect(() => { @@ -62,6 +65,18 @@ const GithubIssueForm = ({ onSubmit }: FormProps) => { [onSubmit] ) + const addIntegration = useCallback( + (integration: Integration) => { + if ( + integrationState.type !== 'initialising' && + integration.type === 'team' + ) { + integrationState.actions.addIntegration(integration.integration) + } + }, + [integrationState] + ) + return ( {(() => { @@ -69,7 +84,33 @@ const GithubIssueForm = ({ onSubmit }: FormProps) => { case 'initialising': return case 'integrate': - return
Please Integrate
+ return ( + +
+
+ + + +
+
+

GitHub

+

+ Integrate with GitHub to import Issues automatically. You + can check the task progress, assignees, due date and more + at a glance. +

+ +
+
+
+ +
+
+ ) case 'issue_select': return ( ) => { ) } +const StyledIntegrationManager = styled.div` + padding: 0 ${({ theme }) => theme.sizes.spaces.l}px; + display: flex; + align-items: center; + height: 100%; + + & > div { + width: 50%; + } + + & .github-issue__form__integrate__icons { + display: flex; + align-items: center; + & > img { + height: ${({ theme }) => theme.sizes.fonts.xl * 2}px; + } + + & > svg { + margin: 0 ${({ theme }) => theme.sizes.spaces.sm}px; + } + } + + & h1 { + font-size: ${({ theme }) => theme.sizes.fonts.xl}px; + margin: ${({ theme }) => theme.sizes.spaces.l}px 0; + } + + & p { + font-size: ${({ theme }) => theme.sizes.fonts.md}px; + margin: ${({ theme }) => theme.sizes.spaces.md}px 0; + } + + & .github-issue__form__integrate__splash { + background-color: #c4c4c4; + height: 300px; + width: 400px; + & > img { + width: 100%; + height: 100%; + } + } +` + interface GithubIssueSelectorProps { integrations: SerializedTeamIntegration[] onImport: (issues: Record[]) => Promise diff --git a/src/design/lib/stores/integrations/index.ts b/src/design/lib/stores/integrations/index.ts index 400bbc5df3..dfb5f6c223 100644 --- a/src/design/lib/stores/integrations/index.ts +++ b/src/design/lib/stores/integrations/index.ts @@ -16,7 +16,7 @@ interface Actions { } export type State = - | { type: 'initialising' } + | { type: 'initialising'; actions: Actions } | { type: 'working' integrations: SerializedTeamIntegration[] From fa1d85a2352b2075391956a64f30bb6cc78e8217 Mon Sep 17 00:00:00 2001 From: Simon Leigh Date: Tue, 10 Aug 2021 09:36:14 +0900 Subject: [PATCH 021/106] fix select all checkbox being selected when no issues --- src/cloud/components/Blocks/forms/GithubIssueForm.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/cloud/components/Blocks/forms/GithubIssueForm.tsx b/src/cloud/components/Blocks/forms/GithubIssueForm.tsx index 3d148eac33..018648f79e 100644 --- a/src/cloud/components/Blocks/forms/GithubIssueForm.tsx +++ b/src/cloud/components/Blocks/forms/GithubIssueForm.tsx @@ -394,7 +394,10 @@ const GithubIssueSelector = ({ 0 && + filteredIssues.length === selectedIssues.size + } />{' '} Title From cafbcae1fd0ac7450b619d779723910dc3454f9f Mon Sep 17 00:00:00 2001 From: Simon Leigh Date: Tue, 10 Aug 2021 10:57:15 +0900 Subject: [PATCH 022/106] get more button --- .../Blocks/forms/GithubIssueForm.tsx | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/cloud/components/Blocks/forms/GithubIssueForm.tsx b/src/cloud/components/Blocks/forms/GithubIssueForm.tsx index 018648f79e..754639f1dc 100644 --- a/src/cloud/components/Blocks/forms/GithubIssueForm.tsx +++ b/src/cloud/components/Blocks/forms/GithubIssueForm.tsx @@ -187,6 +187,7 @@ const GithubIssueSelector = ({ const [currentRepo, setCurrentRepo] = useState(null) const [issues, setIssues] = useState([]) const [selectedIssues, setSelectedIssues] = useState>(new Set()) + const [page, setPage] = useState(1) const [isLoading, setIsLoading] = useState(false) const [search, setSearch] = useState('') const { pushApiErrorMessage } = useToast() @@ -232,6 +233,7 @@ const GithubIssueSelector = ({ useEffect(() => { setIssues([]) setSelectedIssues(new Set()) + setPage(1) if (currentRepo != null) { setIsLoading(true) getAction(integrationRef.current, 'repo:issues', { @@ -245,6 +247,25 @@ const GithubIssueSelector = ({ } }, [currentRepo]) + const getMore = useCallback(async () => { + if (currentRepo != null) { + try { + setIsLoading(true) + const issues = await getAction(integrationRef.current, 'repo:issues', { + owner: currentRepo.owner.login, + repo: currentRepo.name, + page: page + 1, + }) + setPage(page + 1) + setIssues((prev) => prev.concat(issues)) + } catch (err) { + errorHandleRef.current(err) + } finally { + setIsLoading(false) + } + } + }, [currentRepo, page]) + const setIntegration = useCallback( ({ value }) => { const integration = integrations.find(({ id }) => id === value) @@ -434,6 +455,9 @@ const GithubIssueSelector = ({ ))} +
+ +
@@ -42,18 +43,11 @@ const GithubIssueView = ({ block }: ViewProps) => {
From 15615276f81878bfcaf73ee80d013cb0bad76d98 Mon Sep 17 00:00:00 2001 From: Simon Leigh Date: Tue, 10 Aug 2021 11:13:04 +0900 Subject: [PATCH 024/106] remove console.logs --- src/cloud/components/Blocks/views/GithubIssue.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/cloud/components/Blocks/views/GithubIssue.tsx b/src/cloud/components/Blocks/views/GithubIssue.tsx index b48cc5ee2a..d8b1024133 100644 --- a/src/cloud/components/Blocks/views/GithubIssue.tsx +++ b/src/cloud/components/Blocks/views/GithubIssue.tsx @@ -6,7 +6,6 @@ import Icon from '../../../../design/components/atoms/Icon' import { GithubIssueBlock } from '../../../api/blocks' const GithubIssueView = ({ block }: ViewProps) => { - console.log(block) return (

{block.data.title}

From 78b0d45d7f677ae3e5e34171dee8f8783225b203 Mon Sep 17 00:00:00 2001 From: Simon Leigh Date: Mon, 23 Aug 2021 14:49:58 +0900 Subject: [PATCH 025/106] table module --- src/cloud/lib/blocks/table.ts | 207 ++++++++++++++++++++++++++++++++++ 1 file changed, 207 insertions(+) create mode 100644 src/cloud/lib/blocks/table.ts diff --git a/src/cloud/lib/blocks/table.ts b/src/cloud/lib/blocks/table.ts new file mode 100644 index 0000000000..b481b01249 --- /dev/null +++ b/src/cloud/lib/blocks/table.ts @@ -0,0 +1,207 @@ +import { v4 as uuid } from 'uuid' +import { Array as YArray, Map as YMap } from 'yjs' + +export type DataType = + | 'text' + | 'number' + | 'date' + | 'url' + | 'checkbox' + | 'user' + | 'prop' + +export interface Column { + id: string + name: string + data_type: DataType + default?: string +} + +export interface Table { + columns: Column[] + row_data: Map> +} + +export function createTable(columns: Omit[]): Table { + return { + columns: columns.map((col) => ({ ...col, id: uuid() })), + row_data: new Map(), + } +} + +export function addColumn(column: Omit, table: Table): Table { + const id = uuid() + return { ...table, columns: [...table.columns, { id, ...column }] } +} + +export function moveColumn(target: number, column: Column, table: Table) { + const index = table.columns.findIndex((col) => col.id === column.id) + if (index < 0 || target < 0) { + return table + } + + const mutable = table.columns.slice() + mutable.splice(index, 1) + mutable.splice(target, 0, column) + + return { ...table, columns: mutable } +} + +export function deleteColumn(column: Column, table: Table) { + const row_data = new Map(table.row_data) + for (const [key, data] of row_data.entries()) { + row_data.set(key, unset(column.id, data)) + } + const columns = table.columns.filter((col) => col.id !== column.id) + return { columns, row_data } +} + +type Alteration = Partial & { id: Column['id']; fill?: string } + +export function alterColumn( + { id, fill, ...changes }: Alteration, + table: Table +) { + const columns = table.columns.map((col) => + col.id === id ? { ...col, ...changes } : col + ) + const newTable: Table = { ...table, columns } + if (fill != null) { + newTable.row_data = new Map(table.row_data) + for (const [key, data] of newTable.row_data.entries()) { + newTable.row_data.set(key, { ...data, [id]: fill }) + } + } + return newTable +} + +export function setCellData( + rowId: string, + col: Column, + newData: string, + table: Table +) { + const row_data = new Map(table.row_data) + row_data.set(rowId, { ...(row_data.get(rowId) || {}), [col.id]: newData }) + return { ...table, row_data } +} + +export function deleteRowData(id: string, table: Table) { + const row_data = new Map(table.row_data) + row_data.delete(id) + return { ...table, row_data } +} + +function unset( + prop: K, + { [prop]: _remove, ...rest }: T +): Omit { + return rest +} + +function getCols(ytable: YTable): YArray> { + let cols = ytable.get(0) + if (!(cols instanceof YArray)) { + ytable.insert(0, [new YArray()]) + cols = ytable.get(0) + } + return cols as YArray> +} + +function getRows(ytable: YTable): YMap> { + let rows = ytable.get(1) + if (!(rows instanceof YMap)) { + ytable.insert(0, [new YMap()]) + rows = ytable.get(0) + } + return rows as YMap> +} + +function isColumn(col: any): col is Column { + return col.id != null && col.name != null && col.data_type != null +} + +export function parseFrom(ytable: YTable): Table { + let cols = getCols(ytable) + let rows = getRows(ytable) + + const columns: Column[] = [] + for (const col of cols) { + const parsedCol = { + id: col.get('id'), + name: col.get('name'), + data_type: col.get('data_type'), + default: col.get('default'), + } + if (isColumn(parsedCol)) { + columns.push(parsedCol) + } + } + + const row_data: Table['row_data'] = new Map() + for (const [id, row] of rows.entries()) { + row_data.set(id, Object.fromEntries(row.entries())) + } + + return { columns, row_data } +} + +type YTable = YArray> | YMap>> +export function syncTo(ytable: YTable, table: Table, origin?: any) { + let cols = getCols(ytable) + let rows = getRows(ytable) + + if (ytable.doc == null) { + return false + } + + ytable.doc.transact(() => { + resize(table.columns.length, () => new YMap(), cols) + + for (const [i, column] of table.columns.entries()) { + syncObject(column, cols.get(i)) + } + + for (const [key, row] of table.row_data.entries()) { + let map = rows.get(key) + if (map == null) { + map = new YMap() + rows.set(key, map) + } + syncObject(row, map) + } + }, origin) + + return true +} + +function syncObject(obj: Record, yMap: YMap) { + const existingKeys = new Set(yMap.keys()) + for (const [key, value] of Object.entries(obj)) { + existingKeys.delete(key) + const currentValue = yMap.get(key) + if (currentValue !== value) { + yMap.set(key, value) + } + } + + for (const key of existingKeys.values()) { + yMap.delete(key) + } +} + +function resize( + length: number, + fill: (i: number) => T, + arr: YArray +): YArray { + const diff = length - arr.length + if (diff > 0) { + for (let i = 0; i < diff; i++) { + arr.push([fill(arr.length + i)]) + } + } else if (diff < 0) { + arr.delete(arr.length + diff) + } + return arr +} From d3e702446333686ff16189983658369b42e24ca4 Mon Sep 17 00:00:00 2001 From: Simon Leigh Date: Mon, 23 Aug 2021 14:51:11 +0900 Subject: [PATCH 026/106] Editable TextCell --- .../organisms/Blocks/views/Table/TextCell.tsx | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 src/cloud/components/organisms/Blocks/views/Table/TextCell.tsx diff --git a/src/cloud/components/organisms/Blocks/views/Table/TextCell.tsx b/src/cloud/components/organisms/Blocks/views/Table/TextCell.tsx new file mode 100644 index 0000000000..39353c3131 --- /dev/null +++ b/src/cloud/components/organisms/Blocks/views/Table/TextCell.tsx @@ -0,0 +1,64 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react' +import { CellProps } from '.' +import styled from '../../../../../../shared/lib/styled' + +interface TextCellProps extends CellProps { + validation?: (value: string) => boolean +} + +const TextCell = ({ value, onUpdate, validation }: TextCellProps) => { + const [mode, setMode] = useState<'view' | 'edit'>('view') + const [internal, setInternal] = useState(value) + const [hasError, setHasError] = useState(false) + const textAreaRef = useRef(null) + + useEffect(() => { + setInternal(value) + }, [value]) + + const onBlur = useCallback(() => { + if (validation == null || validation(internal)) { + onUpdate(internal) + setMode('view') + } else { + setHasError(true) + } + }, [onUpdate, internal]) + + const onChange: React.ChangeEventHandler = useCallback( + (ev) => { + setInternal(ev.target.value) + }, + [] + ) + + useEffect(() => { + if (mode === 'edit' && textAreaRef.current != null) { + textAreaRef.current.focus() + } + }, [mode]) + + if (mode === 'view') { + return ( + setMode('edit')}>{value} + ) + } + + return ( + +