diff --git a/package-lock.json b/package-lock.json index 5475c12848..b8e97110fe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -96,6 +96,7 @@ "unified": "^9.1.0", "unist-util-visit": "^2.0.3", "use-force-update": "^1.0.7", + "uuid": "^3.4.0", "webpack-bundle-analyzer": "^4.4.2", "y-codemirror": "^2.0.9", "y-websocket": "^1.3.9", @@ -142,6 +143,7 @@ "@types/react-select": "^4.0.11", "@types/shortid": "0.0.29", "@types/terser-webpack-plugin": "^2.2.0", + "@types/uuid": "^8.3.1", "@types/uuid-parse": "^1.0.0", "@types/webpack": "^4.41.24", "@types/webpack-dev-server": "^3.1.7", @@ -15358,6 +15360,12 @@ "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.3.tgz", "integrity": "sha512-FvUupuM3rlRsRtCN+fDudtmytGO6iHJuuRKS1Ss0pG5z8oX0diNEw94UEL7hgDbpN94rgaK5R7sWm6RrSkZuAQ==" }, + "node_modules/@types/uuid": { + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-8.3.1.tgz", + "integrity": "sha512-Y2mHTRAbqfFkpjldbkHGY8JIzRN6XqYRliG8/24FcHm2D2PwW24fl5xMRTVGdrb7iMrwCaIEbLWerGIkXuFWVg==", + "dev": true + }, "node_modules/@types/uuid-parse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@types/uuid-parse/-/uuid-parse-1.0.0.tgz", @@ -38280,7 +38288,6 @@ "version": "3.4.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", - "dev": true, "bin": { "uuid": "bin/uuid" } @@ -53836,6 +53843,12 @@ "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.3.tgz", "integrity": "sha512-FvUupuM3rlRsRtCN+fDudtmytGO6iHJuuRKS1Ss0pG5z8oX0diNEw94UEL7hgDbpN94rgaK5R7sWm6RrSkZuAQ==" }, + "@types/uuid": { + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-8.3.1.tgz", + "integrity": "sha512-Y2mHTRAbqfFkpjldbkHGY8JIzRN6XqYRliG8/24FcHm2D2PwW24fl5xMRTVGdrb7iMrwCaIEbLWerGIkXuFWVg==", + "dev": true + }, "@types/uuid-parse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@types/uuid-parse/-/uuid-parse-1.0.0.tgz", @@ -73018,8 +73031,7 @@ "uuid": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", - "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", - "dev": true + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==" }, "uuid-browser": { "version": "3.1.0", diff --git a/package.json b/package.json index 74163dfac8..f799ae120b 100644 --- a/package.json +++ b/package.json @@ -82,6 +82,7 @@ "@types/react-select": "^4.0.11", "@types/shortid": "0.0.29", "@types/terser-webpack-plugin": "^2.2.0", + "@types/uuid": "^8.3.1", "@types/uuid-parse": "^1.0.0", "@types/webpack": "^4.41.24", "@types/webpack-dev-server": "^3.1.7", @@ -212,6 +213,7 @@ "unified": "^9.1.0", "unist-util-visit": "^2.0.3", "use-force-update": "^1.0.7", + "uuid": "^3.4.0", "webpack-bundle-analyzer": "^4.4.2", "y-codemirror": "^2.0.9", "y-websocket": "^1.3.9", diff --git a/src/cloud/api/blocks/index.ts b/src/cloud/api/blocks/index.ts new file mode 100644 index 0000000000..e45c56091f --- /dev/null +++ b/src/cloud/api/blocks/index.ts @@ -0,0 +1,74 @@ +import { callApi } from '../../lib/client' + +interface BlockType = never> { + type: T + id: string + name: string + children: C[] + data: D + createdAt: string +} + +export type MarkdownBlock = BlockType<'markdown', null> +export type EmbedBlock = BlockType<'embed', { url: string }> +export type GithubIssueBlock = BlockType< + 'github.issue', + any, + MarkdownBlock | EmbedBlock | TableBlock | ContainerBlock +> +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 block +} + +export type BlockCreateRequestBody = Omit +export async function createBlock( + body: BlockCreateRequestBody, + parent: string +): Promise { + const { block } = await callApi(`api/blocks`, { + method: 'post', + json: { ...body, parent }, + }) + + return block +} + +export type BlockUpdateRequestBody = { id: string; type: string } & Partial< + Block +> +export async function updateBlock( + body: BlockUpdateRequestBody +): 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/api/integrations/index.ts b/src/cloud/api/integrations/index.ts index f2ff343f89..75ee27d853 100644 --- a/src/cloud/api/integrations/index.ts +++ b/src/cloud/api/integrations/index.ts @@ -16,3 +16,56 @@ 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:pulls']: any[] + ['repo:issues']: any[] + ['repo:labels']: { + id: number + name: string + color: string + description: string + }[] + ['repo:collaborators']: { id: number; avatar_url: string; login: string }[] +} + +export async function getAction( + integration: Pick, + action: A, + args?: Record +): Promise { + const { data } = await callApi(`/api/integrations/${integration.id}/action`, { + search: { action, ...args }, + }) + return data +} + +export interface IntegrationPostActionTypes { + ['issue:assign']: any + ['issue:update']: any +} + +export async function postAction( + integration: Pick, + action: A, + args?: Record, + body?: any +): Promise { + const { data } = await callApi(`/api/integrations/${integration.id}/action`, { + search: { action, ...args }, + json: body, + method: 'post', + }) + return data +} 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..662efd11c1 --- /dev/null +++ b/src/cloud/components/Blocks/BlockContent.tsx @@ -0,0 +1,304 @@ +import React, { useCallback, useState, useEffect, useRef, useMemo } from 'react' +import { mdiPlus } from '@mdi/js' +import { Block, BlockCreateRequestBody, ContainerBlock } from '../../api/blocks' +import { useDocBlocks } from '../../lib/hooks/useDocBlocks' +import { SerializedDocWithBookmark } from '../../interfaces/db/doc' +import { useModal } from '../../../design/lib/stores/modal' +import BlockTree from './BlockTree' +import styled from '../../../design/lib/styled' +import { find } from '../../../design/lib/utils/tree' +import useRealtime from '../../lib/editor/hooks/useRealtime' +import { BlockView } from './views' +import Scroller from '../../../design/components/atoms/Scroller' +import UpDownList from '../../../design/components/atoms/UpDownList' +import NavigationItem from '../../../design/components/molecules/Navigation/NavigationItem' +import { OverlayScrollbarsComponent } from 'overlayscrollbars-react' +import { usePage } from '../../lib/stores/pageStore' +import { + CollapsableType, + useSidebarCollapse, +} from '../../lib/stores/sidebarCollapse' +import { FoldingProps } from '../../../design/components/atoms/FoldingWrapper' +import { blockEventEmitter } from '../../lib/utils/events' +import { sleep } from '../../../lib/sleep' +import cc from 'classcat' +import { useRouter } from '../../lib/router' + +export interface Canvas extends SerializedDocWithBookmark { + rootBlock: ContainerBlock +} + +export interface FormProps { + onSubmit: (block: BlockCreateRequestBody) => Promise +} + +interface BlockContentProps { + doc: Canvas +} + +const BlockContent = ({ doc }: BlockContentProps) => { + const { currentUserIsCoreMember } = usePage() + const { state, actions, sendingMap } = useDocBlocks(doc.rootBlock.id) + const { closeAllModals } = useModal() + const [currentBlock, setCurrentBlock] = useState(null) + const contentScrollerRef = useRef(null) + const [provider] = useRealtime({ + token: doc.collaborationToken || '', + id: doc.id, + }) + + const scrollToElement = useCallback((elem: HTMLElement | null) => { + if (elem != null && contentScrollerRef.current != null) { + const instance = contentScrollerRef.current.osInstance() + if (instance != null) { + instance.scroll({ el: elem, block: 'center' }, 300) + } + } + }, []) + + const createBlock = useCallback( + async (block: BlockCreateRequestBody) => { + const res = await actions.create(block, doc.rootBlock) + + if (!res.err) { + const block = res.data + setCurrentBlock(block) + await sleep(100) //rendering delay + blockEventEmitter.dispatch({ + event: 'creation', + blockType: block.type, + blockId: block.id, + }) + + closeAllModals() + } + }, + [doc, actions, closeAllModals] + ) + + useEffect(() => { + if (state.type === 'loaded') { + setCurrentBlock((prev) => { + return prev != null + ? find(state.block, (block) => block.id === prev.id) + : null + }) + } + }, [state]) + + const createContainer = useCallback(() => { + return createBlock({ + name: '', + type: 'container', + children: [], + data: null, + }) + }, [createBlock]) + + const { + sideBarOpenedBlocksIdsSet, + toggleItem, + unfoldItem, + foldItem, + } = useSidebarCollapse() + + const getFoldEvents = useCallback( + (type: CollapsableType, key: string, reversed?: boolean) => { + if (reversed) { + return { + fold: () => unfoldItem(type, key), + unfold: () => foldItem(type, key), + toggle: () => toggleItem(type, key), + } + } + + return { + fold: () => foldItem(type, key), + unfold: () => unfoldItem(type, key), + toggle: () => toggleItem(type, key), + } + }, + [toggleItem, unfoldItem, foldItem] + ) + + const navTree = useMemo(() => { + if (state.type === 'loading') { + return [] + } + + return state.block.children.map((child) => + getFoldedChild(child, sideBarOpenedBlocksIdsSet, getFoldEvents) + ) + }, [getFoldEvents, state, sideBarOpenedBlocksIdsSet]) + + const { state: routerState } = useRouter() + const docIsNew = !!routerState?.new + const previousDocRef = useRef<{ + blockType: 'container' | 'markdown' | 'embed' | 'table' | 'github.issue' + blockId: string + }>() + useEffect(() => { + if (state.type === 'loading') { + return + } + + if (docIsNew && previousDocRef.current?.blockId !== state.block.id) { + previousDocRef.current = { + blockType: state.block.type, + blockId: state.block.id, + } + if ( + state.block.children != null && + state.block.children.length > 0 && + state.block.children[0] != null + ) { + async function focusBlock({ + type, + id, + }: { + type: 'container' | 'markdown' | 'embed' | 'table' | 'github.issue' + id: string + }) { + await sleep(300).finally(() => { + blockEventEmitter.dispatch({ + event: 'creation', + blockType: type, + blockId: id, + }) + }) + } + focusBlock(state.block.children[0]) + } + } + }, [state, docIsNew]) + + if (state.type === 'loading') { + return
loading
+ } + + return ( + +
+ + + {navTree.map((child) => ( + + ))} + + + +
+ + 0 + ? state.block.children[0] + : state.block) + } + actions={actions} + canvas={doc} + realtime={provider} + setCurrentBlock={setCurrentBlock} + scrollToElement={scrollToElement} + currentUserIsCoreMember={currentUserIsCoreMember} + sendingMap={sendingMap} + /> + +
+
+ + ) +} + +const StyledBlockContent = styled.div` + display: flex; + flex: 1 1 auto; + overflow: hidden; + + .block__editor__view__wrapper--padding-less { + padding: 0 !important; + } + + & > .block__editor__nav { + padding-top: ${({ theme }) => theme.sizes.spaces.df}px; + display: flex; + flex-direction: column; + border-right: 1px solid ${({ theme }) => theme.colors.border.main}; + width: 240px; + flex: 0 0 auto; + + .block__editor__nav__scroller { + flex-grow: 1; + } + } + + & .block__editor__view { + flex: 1 1 auto; + height: 100%; + position: relative; + width: 100%; + overflow: hidden; + } + + & .block__editor__view__wrapper { + width: 100%; + height: 100%; + padding: ${({ theme }) => theme.sizes.spaces.md}px 0; + position: relative; + } +` + +function getFoldedChild( + targetBlock: Block, + sideBarOpenedBlocksIdsSet: Set, + getFoldEvents: ( + type: CollapsableType, + key: string, + reversed?: boolean | undefined + ) => FoldingProps +): Block & { + folded?: boolean + folding?: FoldingProps +} { + if ((targetBlock.children || []).length === 0) { + return targetBlock + } + + return { + ...targetBlock, + folded: sideBarOpenedBlocksIdsSet.has(targetBlock.id), + folding: getFoldEvents('blocks', targetBlock.id, true), + children: targetBlock.children.map((child) => + getFoldedChild(child, sideBarOpenedBlocksIdsSet, getFoldEvents) + ), + } as any +} + +export default BlockContent diff --git a/src/cloud/components/Blocks/BlockCreationModal.tsx b/src/cloud/components/Blocks/BlockCreationModal.tsx new file mode 100644 index 0000000000..2591e6ac82 --- /dev/null +++ b/src/cloud/components/Blocks/BlockCreationModal.tsx @@ -0,0 +1,108 @@ +import { + mdiCodeTags, + mdiFileDocumentOutline, + mdiPackageVariantClosed, + mdiTable, +} from '@mdi/js' +import React from 'react' +import Button from '../../../design/components/atoms/Button' +import Icon from '../../../design/components/atoms/Icon' +import LeftRightList from '../../../design/components/atoms/LeftRightList' +import styled from '../../../design/lib/styled' + +interface BlockCreationModalProps { + onContainerCreation?: () => void + onMarkdownCreation?: () => void + onTableCreation?: () => void + onEmbedCreation?: () => void +} + +const BlockCreationModal = ({ + onMarkdownCreation, + onEmbedCreation, + onTableCreation, + onContainerCreation, +}: BlockCreationModalProps) => ( + + + {onContainerCreation != null && ( + + )} + {onMarkdownCreation != null && ( + + )} + {onTableCreation != null && ( + + )} + {onEmbedCreation != null && ( + + )} + + +) + +const Container = styled.div` + .block__creation__list { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + } + + button { + height: auto; + max-height: none; + padding: ${({ theme }) => theme.sizes.spaces.df}px + ${({ theme }) => theme.sizes.spaces.md}px; + .button__label { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + .icon { + margin-bottom: ${({ theme }) => theme.sizes.spaces.sm}px; + } + span { + font-size: ${({ theme }) => theme.sizes.fonts.md}px; + } + } + } + + button + button { + margin-left: ${({ theme }) => theme.sizes.spaces.md}px; + } +` + +export default BlockCreationModal diff --git a/src/cloud/components/Blocks/BlockEditor.tsx b/src/cloud/components/Blocks/BlockEditor.tsx new file mode 100644 index 0000000000..1db647b65d --- /dev/null +++ b/src/cloud/components/Blocks/BlockEditor.tsx @@ -0,0 +1,65 @@ +import { mdiDotsHorizontal } from '@mdi/js' +import React from 'react' +import { TopbarControlProps } from '../../../design/components/organisms/Topbar' +import { useModal } from '../../../design/lib/stores/modal' +import { ContainerBlock } from '../../api/blocks' +import { SerializedDocWithBookmark } from '../../interfaces/db/doc' +import { SerializedTeam } from '../../interfaces/db/team' +import { usePage } from '../../lib/stores/pageStore' +import ApplicationPage from '../ApplicationPage' +import ApplicationTopbar from '../ApplicationTopbar' +import InviteCTAButton from '../buttons/InviteCTAButton' +import NewDocContextMenu from '../DocPage/NewDocContextMenu' +import BlockContent from './BlockContent' + +interface BlockEditorProps { + doc: SerializedDocWithBookmark & { rootBlock: ContainerBlock } + team: SerializedTeam +} + +const BlockEditor = ({ doc, team }: BlockEditorProps) => { + const { openContextModal } = useModal() + const { currentUserIsCoreMember, permissions = [] } = usePage() + + return ( + + , + }, + { + type: 'separator', + }, + { + variant: 'icon', + iconPath: mdiDotsHorizontal, + onClick: (event) => { + openContextModal( + event, + , + { + alignment: 'bottom-right', + removePadding: true, + hideBackground: true, + } + ) + }, + }, + ] as TopbarControlProps[] + } + /> + + + ) +} + +export default BlockEditor diff --git a/src/cloud/components/Blocks/BlockIcon.tsx b/src/cloud/components/Blocks/BlockIcon.tsx new file mode 100644 index 0000000000..1284b18e37 --- /dev/null +++ b/src/cloud/components/Blocks/BlockIcon.tsx @@ -0,0 +1,36 @@ +import { + mdiCodeTags, + mdiFileDocumentOutline, + mdiGithub, + mdiPackageVariantClosed, + mdiTable, +} from '@mdi/js' +import React, { useMemo } from 'react' +import Icon, { IconSize } from '../../../design/components/atoms/Icon' +import { Block } from '../../api/blocks' + +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 BlockIcon diff --git a/src/cloud/components/Blocks/BlockLayout.tsx b/src/cloud/components/Blocks/BlockLayout.tsx new file mode 100644 index 0000000000..e9190a0764 --- /dev/null +++ b/src/cloud/components/Blocks/BlockLayout.tsx @@ -0,0 +1,121 @@ +import React, { PropsWithChildren } from 'react' +import { LoadingButton } from '../../../design/components/atoms/Button' +import WithTooltip from '../../../design/components/atoms/WithTooltip' +import styled from '../../../design/lib/styled' +import { generateId } from '../../../lib/string' +import cc from 'classcat' + +interface BlockLayoutProps { + isRootBlock?: boolean + controls?: { + id?: string + disabled?: boolean + active?: boolean + icon?: React.ReactNode + iconPath?: string + onClick: (event: React.MouseEvent) => void + tooltip?: string + spinning?: boolean + }[] +} + +const BlockLayout = ({ + isRootBlock = false, + children, + controls = [], +}: PropsWithChildren) => ( + + {controls.length > 0 && ( +
+ {controls.map((control) => ( + + + + ))} +
+ )} +
{children}
+
+) + +function hexToRgb(hex: string) { + try { + return hex + .replace( + /^#?([a-f\d])([a-f\d])([a-f\d])$/i, + (_m, r, g, b) => '#' + r + r + g + g + b + b + ) + .substring(1) + .match(/.{2}/g)! + .map((x) => parseInt(x, 16)) + } catch (error) { + return '' + } +} + +const BlockLayoutContainer = styled.div` + width: 100%; + position: relative; + z-index: 0; + transition: all 0.3s ease-in-out; + background: ${({ theme }) => theme.colors.background.primary}; + padding: ${({ theme }) => theme.sizes.spaces.md}px + ${({ theme }) => theme.sizes.spaces.xl}px; + + .block__layout__controls { + transition: all 0.3s ease-in-out; + position: absolute; + right: 5px; + top: -13px; + border: 1px solid ${({ theme }) => theme.colors.border.main}; + background: ${({ theme }) => theme.colors.background.primary}; + border-radius: ${({ theme }) => theme.borders.radius}px; + + button + button { + margin: 0; + } + } + + &.block__layout--hover { + .block__layout__controls { + display: none; + } + &:hover { + .block__layout__controls { + display: block; + } + + &:not(.block__layout--empty) { + background: ${({ theme }) => { + const rgba = hexToRgb(theme.colors.background.secondary) + return `rgba(${rgba[0]}, ${rgba[1]}, ${rgba[2]}, 0.4)` + }}; + } + } + } + + &.block__layout--static { + } +` + +export default BlockLayout diff --git a/src/cloud/components/Blocks/BlockToolbar.tsx b/src/cloud/components/Blocks/BlockToolbar.tsx new file mode 100644 index 0000000000..059ebef28a --- /dev/null +++ b/src/cloud/components/Blocks/BlockToolbar.tsx @@ -0,0 +1,30 @@ +import React, { PropsWithChildren } from 'react' +import Portal from '../../../design/components/atoms/Portal' +import Toolbar, { + ToolbarControlProps, +} from '../../../design/components/organisms/Toolbar' + +const BlockToolbar = ({ + controls = [], + children, +}: PropsWithChildren<{ + controls?: ToolbarControlProps[] +}>) => { + const portalContainer = document.getElementById( + 'block__editor__view__toolbar-portal' + ) + + if (portalContainer == null) { + return null + } + + return ( + + + {children} + + + ) +} + +export default BlockToolbar diff --git a/src/cloud/components/Blocks/BlockTree.tsx b/src/cloud/components/Blocks/BlockTree.tsx new file mode 100644 index 0000000000..59a6c31089 --- /dev/null +++ b/src/cloud/components/Blocks/BlockTree.tsx @@ -0,0 +1,138 @@ +import React from 'react' +import { mdiTrashCan } from '@mdi/js' +import { Block } from '../../api/blocks' +import styled from '../../../design/lib/styled' +import BlockIcon from './BlockIcon' +import NavigationItem from '../../../design/components/molecules/Navigation/NavigationItem' +import { min } from 'ramda' +import cc from 'classcat' +import { FoldingProps } from '../../../design/components/atoms/FoldingWrapper' +import { blockTitle } from '../../lib/utils/blocks' +import { BlockActionRemove } from '../../lib/hooks/useDocBlocks' + +interface BlockTreeProps { + idPrefix?: string + root: Block & { folding?: FoldingProps; folded?: boolean } + onSelect: (block: Block) => void + onDelete?: BlockActionRemove + active?: Block + depth?: number + className?: string + showFoldEvents?: boolean + sendingMap: Map +} + +const BlockTree = ({ + idPrefix, + root, + onSelect, + onDelete, + depth, + active, + className, + showFoldEvents, + sendingMap, +}: BlockTreeProps) => { + const parentDepth = min(depth || 1, 6) + return ( + 0 && 'block__editor__nav--tree', + parentDepth === 1 && `block__editor__nav--tree-root`, + className, + ])} + depth={parentDepth} + > + }} + labelClick={() => onSelect(root)} + controls={ + onDelete == null || depth === 0 + ? [] + : [ + { + icon: mdiTrashCan, + onClick: () => onDelete(root), + disabled: sendingMap.has(root.id), + spinning: sendingMap.get(root.id) === 'delete-block', + }, + ] + } + /> + {(!showFoldEvents || !root.folded) && + root.children.length > 0 && + (root.children as Block[]).map((child: Block) => ( + + ))} + + ) +} + +const StyledBlockTree = styled.div<{ depth: number }>` + position: relative; + width: 100%; + + &.block__editor__nav--tree-root { + &:hover { + &::before, + .block__tree::before { + opacity: 1 !important; + } + .block__editor__nav--item::before { + opacity: 1 !important; + } + } + } + + &.block__editor__nav--tree { + &::before { + content: ''; + position: absolute; + width: 1px; + bottom: 13px; + left: ${({ depth }) => (depth as number) * 8 + 4}px; + background: ${({ theme }) => theme.colors.border.second}; + height: calc(100% - 26px); + z-index: 1; + pointer-events: none; + filter: brightness(120%); + opacity: 0; + } + } + + .block__editor__nav--item:first-of-type { + &::before { + content: ''; + position: absolute; + width: 8px; + height: 1px; + background: ${({ theme }) => theme.colors.border.second}; + top: 13px; + left: ${({ depth }) => (depth as number) * 8 - 4}px; + z-index: 1; + pointer-events: none; + filter: brightness(120%); + opacity: 0; + } + } +` + +export default BlockTree diff --git a/src/cloud/components/Blocks/data/GithubAssigneesData.tsx b/src/cloud/components/Blocks/data/GithubAssigneesData.tsx new file mode 100644 index 0000000000..b5e4fe0d1b --- /dev/null +++ b/src/cloud/components/Blocks/data/GithubAssigneesData.tsx @@ -0,0 +1,154 @@ +import { StyledUserIcon } from '../../UserIcon' +import React, { useCallback, useEffect, useRef, useState } from 'react' +import { useModal } from '../../../../design/lib/stores/modal' +import { getAction, postAction } from '../../../api/integrations' +import styled from '../../../../design/lib/styled' +import Spinner from '../../../../design/components/atoms/Spinner' +import { useToast } from '../../../../design/lib/stores/toast' +import { BlockDataProps } from './types' +import { GithubIssueBlock } from '../../../api/blocks' +import SearchableOptionListPopup from '../../SearchableOptionListPopup' +import Flexbox from '../../../../design/components/atoms/Flexbox' + +interface Assignee { + id: number + avatar_url: string + login: string +} + +const GitHubAssigneesData = ({ + data, + onUpdate, +}: BlockDataProps) => { + const { openContextModal, closeAllModals } = useModal() + const { pushApiErrorMessage } = useToast() + + const addAssignees = useCallback( + async (assignees: string[]) => { + try { + const [owner, repo] = data.repository.full_name.split('/') + const issue = await postAction( + { id: data.integrationId }, + 'issue:assign', + { owner, repo, issue_number: data.number }, + { assignees } + ) + await onUpdate({ ...data, ...issue }) + closeAllModals() + } catch (error) { + pushApiErrorMessage(error) + } + }, + [data, closeAllModals, onUpdate, pushApiErrorMessage] + ) + + const openAssigneeSelect: React.MouseEventHandler = useCallback( + (ev) => { + //TOFIX PREVENT GITHUB UPDATE FOR NOW + return + openContextModal( + ev, + + ) + }, + [openContextModal, addAssignees, data] + ) + + return ( + + + {data.assignees?.map((user: Assignee) => ( + + {user.login[0]} + + ))} + + + ) +} + +const Container = styled.div`` + +interface AssigneeSelectProps { + data: GithubIssueBlock['data'] + onSelect: (names: string[]) => Promise +} + +const AssigneeSelect = ({ data, onSelect }: AssigneeSelectProps) => { + const [users, setUsers] = useState(null) + const { pushApiErrorMessage } = useToast() + const [filter, setFilter] = useState('') + + const pushErrorRef = useRef(pushApiErrorMessage) + useEffect(() => { + pushErrorRef.current = pushApiErrorMessage + }, [pushApiErrorMessage]) + + useEffect(() => { + let cancel = false + const cb = async () => { + try { + if ( + data.integrationId != null && + data.repository != null && + data.repository.full_name != null + ) { + const [owner, repo] = data.repository.full_name.split('/') + const users = await getAction( + { id: data.integrationId }, + 'repo:collaborators', + { owner, repo } + ) + if (!cancel) { + setUsers(users) + } + } + } catch (error) { + pushErrorRef.current(error) + } + } + cb() + return () => { + cancel = true + } + }, [data]) + + if (users == null) { + return + } + + return ( + + { + return { + id: `person-${user.id}`, + icon: ( + + {user.login[0]} + + ), + label: user.login, + checked: false, + onClick: () => onSelect([user.login]), + } + })} + /> + + ) +} + +const GithubUserSelectContainer = styled.div` + & .user__select__item { + display: flex; + cursor: pointer; + align-items: center; + & .user__icon { + margin-right: ${({ theme }) => theme.sizes.spaces.df}px; + } + } +` +export default GitHubAssigneesData diff --git a/src/cloud/components/Blocks/data/GithubLabelsData.tsx b/src/cloud/components/Blocks/data/GithubLabelsData.tsx new file mode 100644 index 0000000000..a1683a5c38 --- /dev/null +++ b/src/cloud/components/Blocks/data/GithubLabelsData.tsx @@ -0,0 +1,167 @@ +import styled from '../../../../design/lib/styled' +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useModal } from '../../../../design/lib/stores/modal' +import { getAction, postAction } from '../../../api/integrations' +import Spinner from '../../../../design/components/atoms/Spinner' +import { useToast } from '../../../../design/lib/stores/toast' +import { BlockDataProps } from './types' +import { GithubIssueBlock } from '../../../api/blocks' +import SearchableOptionListPopup from '../../SearchableOptionListPopup' +import Flexbox from '../../../../design/components/atoms/Flexbox' + +interface Label { + name: string + color: string + description: string +} + +const GithubLabelsData = ({ + data, + onUpdate, +}: BlockDataProps) => { + const { openContextModal, closeAllModals } = useModal() + const { pushApiErrorMessage } = useToast() + + const addLabel = useCallback( + async (toAdd: Label[]) => { + try { + const labels = data.labels + .concat(toAdd) + .map((label: Label) => label.name) + const [owner, repo] = data.repository.full_name.split('/') + const issue = await postAction( + { id: data.integrationId }, + 'issue:update', + { owner, repo, issue_number: data.number }, + { labels } + ) + await onUpdate({ ...data, ...issue }) + closeAllModals() + } catch (error) { + pushApiErrorMessage(error) + } + }, + [data, closeAllModals, onUpdate, pushApiErrorMessage] + ) + + const openSetLabelSelect: React.MouseEventHandler = useCallback( + (ev) => { + //TOFIX PREVENT GITHUB UPDATE FOR NOW + return + openContextModal( + ev, + + ) + }, + [data, addLabel, openContextModal] + ) + + return ( + +
    + {data.labels.map((label: Label) => ( +
  • + {label.name} +
  • + ))} +
+
+ ) +} + +const StyledGithubLabels = styled.div` + & > ul { + margin: 0; + padding: 0; + display: flex; + list-style: none; + & > li { + margin-right: ${({ theme }) => theme.sizes.spaces.xsm}px; + padding: ${({ theme }) => theme.sizes.spaces.xsm}px; + border-radius: 5px; + font-size: ${({ theme }) => theme.sizes.fonts.sm}px; + text-shadow: 0 0 3px #000; + white-space: nowrap; + } + } +` + +interface LabelSelectProps { + data: GithubIssueBlock['data'] + onUpdate: (labels: Label[]) => Promise +} + +const GithubLabelsSelect = ({ data, onUpdate }: LabelSelectProps) => { + const [labels, setLabels] = useState(null) + const { pushApiErrorMessage } = useToast() + const [query, setQuery] = useState('') + + const pushErrorRef = useRef(pushApiErrorMessage) + useEffect(() => { + pushErrorRef.current = pushApiErrorMessage + }, [pushApiErrorMessage]) + + useEffect(() => { + let cancel = false + const cb = async () => { + try { + if ( + data.integrationId != null && + data.repository != null && + data.repository.full_name != null + ) { + const [owner, repo] = data.repository.full_name.split('/') + const labels = await getAction( + { id: data.integrationId }, + 'repo:labels', + { owner, repo } + ) + if (!cancel) { + setLabels(labels) + } + } + } catch (error) { + pushErrorRef.current(error) + } + } + cb() + return () => { + cancel = true + } + }, [data]) + + const filteredLabels = useMemo(() => { + if (labels == null) { + return [] + } + const matches = labels.filter( + (label) => label.name.includes(query) || label.description.includes(query) + ) + return matches.map((label) => { + return { + label: ( + +

{label.name}

+
{label.description || 'No description'}
+
+ ), + onClick: () => onUpdate([label]), + } + }) + }, [labels, onUpdate, query]) + + if (labels == null) { + return + } + + return ( + + ) +} + +export default GithubLabelsData diff --git a/src/cloud/components/Blocks/data/GithubStatusData.tsx b/src/cloud/components/Blocks/data/GithubStatusData.tsx new file mode 100644 index 0000000000..d96608c5fa --- /dev/null +++ b/src/cloud/components/Blocks/data/GithubStatusData.tsx @@ -0,0 +1,120 @@ +import { + mdiAlertCircleCheckOutline, + mdiAlertCircleOutline, + mdiCheck, +} from '@mdi/js' +import React, { useCallback } from 'react' +import Icon, { + SuccessIcon, + WarningIcon, +} from '../../../../design/components/atoms/Icon' +import { useModal } from '../../../../design/lib/stores/modal' +import { useToast } from '../../../../design/lib/stores/toast' +import styled from '../../../../design/lib/styled' +import { GithubIssueBlock } from '../../../api/blocks' +import { postAction } from '../../../api/integrations' +import { capitalize } from '../../../lib/utils/string' +import { BlockDataProps } from './types' + +const GithubStatusData = ({ + data, + onUpdate, +}: BlockDataProps) => { + const { openContextModal, closeAllModals } = useModal() + const { pushApiErrorMessage } = useToast() + + const updateState = useCallback( + async (state: 'open' | 'closed') => { + try { + const [owner, repo] = data.repository.full_name.split('/') + const issue = await postAction( + { id: data.integrationId }, + 'issue:update', + { owner, repo, issue_number: data.number }, + { state } + ) + await onUpdate({ ...data, ...issue }) + closeAllModals() + } catch (error) { + pushApiErrorMessage(error) + } + }, + [data, onUpdate, closeAllModals, pushApiErrorMessage] + ) + + const openStateSelect: React.MouseEventHandler = useCallback( + (ev) => { + //TOFIX PREVENT GITHUB UPDATE FOR NOW + return + openContextModal( + ev, + + ) + }, + [openContextModal, data, updateState] + ) + + return ( + + {data.state === 'open' ? ( + + ) : ( + + )} + {capitalize(data.state)} + + ) +} + +const StyledStatus = styled.div` + display: flex; + align-items: center; + + & > svg:first-child { + margin-right: ${({ theme }) => theme.sizes.spaces.xsm}px; + } + + & > span { + flex-grow: 1; + text-align: left; + } +` + +interface StateSelectProps { + data: GithubIssueBlock['data'] + onUpdate: (state: 'closed' | 'open') => Promise +} + +const GithubStateSelect = ({ data, onUpdate }: StateSelectProps) => { + return ( + +
onUpdate('open')}> + + Open + {data.state === 'open' && } +
+
onUpdate('closed')}> + + Closed + {data.state === 'closed' && } +
+
+ ) +} + +const StateSelectContainer = styled.div` + & > div { + cursor: pointer; + display: flex; + align-items: center; + padding: ${({ theme }) => theme.sizes.spaces.sm}px 0; + & svg { + margin-right: ${({ theme }) => theme.sizes.spaces.df}px; + } + & > span { + flex-grow: 1; + } + } +` + +export default GithubStatusData diff --git a/src/cloud/components/Blocks/data/types.ts b/src/cloud/components/Blocks/data/types.ts new file mode 100644 index 0000000000..c5b3954dd6 --- /dev/null +++ b/src/cloud/components/Blocks/data/types.ts @@ -0,0 +1,6 @@ +import { Block } from '../../../api/blocks' + +export interface BlockDataProps { + data: T['data'] + onUpdate: (data: T['data']) => Promise +} diff --git a/src/cloud/components/Blocks/forms/EmbedForm.tsx b/src/cloud/components/Blocks/forms/EmbedForm.tsx new file mode 100644 index 0000000000..2f0e3a6be7 --- /dev/null +++ b/src/cloud/components/Blocks/forms/EmbedForm.tsx @@ -0,0 +1,105 @@ +import { capitalize } from 'lodash' +import React, { useRef, useState, useCallback, FormEvent } from 'react' +import { useEffectOnce } from 'react-use' +import Form from '../../../../design/components/molecules/Form' +import FormRow from '../../../../design/components/molecules/Form/templates/FormRow' +import FormRowItem from '../../../../design/components/molecules/Form/templates/FormRowItem' +import { BlockCreateRequestBody } from '../../../api/blocks' +import { useI18n } from '../../../lib/hooks/useI18n' +import { lngKeys } from '../../../lib/i18n/types' + +interface EmbedFormProps { + onSubmit: (block: BlockCreateRequestBody) => Promise +} +const EmbedForm = ({ onSubmit }: EmbedFormProps) => { + const inputRef = useRef(null) + const [url, setUrl] = useState('') + const { translate } = useI18n() + + const submit = useCallback( + async (event: FormEvent) => { + event.preventDefault() + try { + const nameRegex = new RegExp( + /^(?:https?:\/\/)?(?:www\.)?([^\/?#]+)(?:[\/?#]|$)/, + 'gi' + ) + const regexResult = nameRegex.exec(url) + + let name = 'Embed' + if (regexResult != null) { + let splits = regexResult[1].split('.') + + if (splits.length > 0) { + splits = splits.slice(0, -1) + } + + name = capitalize(splits.join('')) + } + + await onSubmit({ + name, + type: 'embed', + children: [], + data: { url: getEmbedURL(url) }, + }) + } finally { + } + }, + [onSubmit, url] + ) + + useEffectOnce(() => { + if (inputRef.current != null) { + inputRef.current.focus() + } + }) + + return ( +
+ + setUrl(ev.target.value), + }, + }} + /> + + +
+ ) +} + +function getEmbedURL(url: string) { + const isFigmaDefaultURL = /^(?:https:\/\/)?(?:www\.)?figma\.com\/(file|proto)\/([0-9a-zA-Z]{22,128})(?:\/([^\?\n\r\/]+)?((?:\?[^\/]*?node-id=([^&\n\r\/]+))?[^\/]*?)(\/duplicate)?)?$/.test( + url + ) + if (isFigmaDefaultURL) { + return `https://www.figma.com/embed?embed_host=share&url=${encodeURIComponent( + url + )}` + } + const isYoutubeVideoURL = new RegExp( + /^(?:https:\/\/)?(?:m.)?(?:www\.)?(?:(?:youtube\.com\/watch\?v=)|(?:youtu.be)\/)([A-z0-9_-]*)/, + 'gim' + ).exec(url) + if (isYoutubeVideoURL != null) { + return `https://www.youtube.com/embed/${isYoutubeVideoURL[1]}` + } + return url +} + +export default EmbedForm diff --git a/src/cloud/components/Blocks/forms/GithubIssueForm.tsx b/src/cloud/components/Blocks/forms/GithubIssueForm.tsx new file mode 100644 index 0000000000..04319a0c06 --- /dev/null +++ b/src/cloud/components/Blocks/forms/GithubIssueForm.tsx @@ -0,0 +1,617 @@ +import { mdiDownloadOutline, mdiGithub, mdiPlus } from '@mdi/js' +import React, { useMemo, useState, useEffect, useCallback, useRef } from 'react' +import Button from '../../../../design/components/atoms/Button' +import Icon from '../../../../design/components/atoms/Icon' +import Scroller from '../../../../design/components/atoms/Scroller' +import Spinner from '../../../../design/components/atoms/Spinner' +import { CheckboxWithLabel } from '../../../../design/components/molecules/Form/atoms/FormCheckbox' +import FormInput from '../../../../design/components/molecules/Form/atoms/FormInput' +import FormSelect from '../../../../design/components/molecules/Form/atoms/FormSelect' +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 { 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 = + | { stage: 'initialising' } + | { stage: 'integrate' } + | { stage: 'issue_select'; integrations: SerializedTeamIntegration[] } + +const GithubIssueForm = ({ onSubmit }: FormProps) => { + const integrationState = useTeamIntegrations() + const { team } = usePage() + 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] + ) + + const addIntegration = useCallback( + (integration: Integration) => { + if ( + integrationState.type !== 'initialising' && + integration.type === 'team' + ) { + integrationState.actions.addIntegration(integration.integration) + } + }, + [integrationState] + ) + + return ( + + {(() => { + switch (state.stage) { + case 'initialising': + return + case '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 { + max-width: 96%; + height: auto; + max-height: max-content; + + & > img { + width: 100%; + height: 100%; + } + } +` + +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 perPagePerQuery = 100 + +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 [searchScope, setSearchScope] = useState<{ + value: 'repo:issues' | 'repo:pulls' + label: string + }>({ + label: 'Issues', + value: 'repo:issues', + }) + 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() + + 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', { per_page: perPagePerQuery }) + .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, + per_page: perPagePerQuery, + }) + .then((repos) => { + setRepos(repos) + setCurrentRepo(repos[0] || null) + }) + .finally(() => setIsLoading(false)) + .catch(errorHandleRef.current) + } + }, [currentOrg]) + + useEffect(() => { + setIssues([]) + setSelectedIssues(new Set()) + setPage(1) + const integrationId = integrationRef.current.id + if (currentRepo != null) { + setIsLoading(true) + getAction(integrationRef.current, searchScope.value, { + owner: currentRepo.owner.login, + repo: currentRepo.name, + per_page: perPagePerQuery, + }) + .then((issues) => + issues.map((issue) => { + issue.integrationId = integrationId + return issue + }) + ) + .then(setIssues) + .finally(() => setIsLoading(false)) + .catch(errorHandleRef.current) + setIsLoading(false) + } + }, [currentRepo, searchScope.value]) + + const getMore = useCallback(async () => { + if (currentRepo != null) { + try { + setIsLoading(true) + const integrationId = integrationRef.current.id + const issues = ( + await getAction(integrationRef.current, searchScope.value, { + owner: currentRepo.owner.login, + repo: currentRepo.name, + page: page + 1, + per_page: perPagePerQuery, + }) + ).map((issue) => { + issue.integrationId = integrationId + return issue + }) + setPage(page + 1) + setIssues((prev) => prev.concat(issues)) + } catch (err) { + errorHandleRef.current(err) + } finally { + setIsLoading(false) + } + } + }, [currentRepo, page, searchScope.value]) + + 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 = useCallback(() => { + const allIssuesAreSelected = + filteredIssues.length > 0 && filteredIssues.length === selectedIssues.size + if (!allIssuesAreSelected) { + setSelectedIssues(new Set(issues)) + } else { + setSelectedIssues(new Set()) + } + }, [issues, filteredIssues.length, selectedIssues.size]) + + const toggleIssue = useCallback((issue: Issue) => { + return () => { + setSelectedIssues((old) => { + const newSet = new Set(old) + if (!newSet.has(issue)) { + newSet.add(issue) + } else { + newSet.delete(issue) + } + return newSet + }) + } + }, []) + + const runImport = useCallback(async () => { + try { + setIsLoading(true) + await onImport(Array.from(selectedIssues.values())) + } finally { + setIsLoading(false) + } + }, [selectedIssues, onImport]) + + return ( +
+ + + + + + + + + + + + + + + setSearch(ev.target.value)} + /> + + + + + + + + + + + + {searchScope.value === 'repo:issues' && } + + + + {filteredIssues.map((issue) => ( + + + + + + + {searchScope.value === 'repo:issues' && ( + + )} + + ))} + +
+ 0 && + filteredIssues.length === selectedIssues.size + } + label={'Title'} + /> + NumberAssigneesStatusLabelsLinkedPR
+ + {issue.number} + {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} +
+ {search.trim() !== '' ? ( + filteredIssues.length === 0 ? ( +
+ No results correspond to your search... +
+ ) : null + ) : currentRepo != null ? ( + issues.length !== 0 && issues.length % perPagePerQuery === 0 ? ( +
+ +
+ ) : ( +
+ No more{' '} + {searchScope.value === 'repo:issues' ? 'issues' : 'pull requests'}{' '} + could be found... +
+ ) + ) : ( +
+ Please select a repository... +
+ )} +
+
+ +
+
+ ) +} + +const GithubIssueFormLayout = (props: React.PropsWithChildren<{}>) => { + return ( + +

+ GitHub +

+
{props.children}
+
+ ) +} + +const StyledGithubIssueForm = styled.div` + height: 80vh; + display: flex; + flex-direction: column; + + .github-issue__form__placeholder { + padding: ${({ theme }) => theme.sizes.spaces.df}px 0; + text-align: center; + color: ${({ theme }) => theme.colors.text.subtle}; + } + + & .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__more { + display: flex; + justify-content: end; + margin-top: ${({ theme }) => theme.sizes.spaces.md}px; + } + + & .github-issue__form__action { + display: flex; + align-items: center; + justify-content: center; + button { + width: 100%; + cursor: pointer; + display: flex; + align-items: center; + padding: ${({ theme }) => theme.sizes.spaces.sm}px + ${({ theme }) => theme.sizes.spaces.df}px; + } + } + + & 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/props/BoostUserProp.tsx b/src/cloud/components/Blocks/props/BoostUserProp.tsx new file mode 100644 index 0000000000..98ab202755 --- /dev/null +++ b/src/cloud/components/Blocks/props/BoostUserProp.tsx @@ -0,0 +1,131 @@ +import { mdiAccountCircleOutline, mdiClose } from '@mdi/js' +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import Icon from '../../../../design/components/atoms/Icon' +import { useModal } from '../../../../design/lib/stores/modal' +import styled from '../../../../design/lib/styled' +import { SerializedUser } from '../../../interfaces/db/user' +import { useI18n } from '../../../lib/hooks/useI18n' +import { lngKeys } from '../../../lib/i18n/types' +import { usePage } from '../../../lib/stores/pageStore' +import DocPropertyValueButton from '../../DocProperties/DocPropertyValueButton' +import SearchableOptionListPopup from '../../SearchableOptionListPopup' +import UserIcon from '../../UserIcon' +import { BlockPropertyProps } from './types' + +const BoostUserProp = ({ + value, + onUpdate, + currentUserIsCoreMember, +}: BlockPropertyProps) => { + const { permissions = [] } = usePage() + const { openContextModal, closeAllModals } = useModal() + const { translate } = useI18n() + + const user = useMemo(() => { + return permissions + .map((perm) => perm.user) + .find((user) => user.id === value) + }, [permissions, value]) + + const onUpdateRef = useRef(onUpdate) + useEffect(() => { + onUpdateRef.current = onUpdate + }, [onUpdate]) + + const openSelector: React.MouseEventHandler = useCallback( + (ev) => { + openContextModal( + ev, + perm.user)} + showClearOption={user != null} + onSelect={(user) => { + if (user != null) { + onUpdateRef.current(user.id) + } else { + onUpdateRef.current('') + } + return closeAllModals() + }} + />, + { width: 300 } + ) + }, + [permissions, openContextModal, closeAllModals, user] + ) + + return ( + + + {user != null ? ( + + ) : ( + translate(lngKeys.Unassigned) + )} + + + ) +} + +const Container = styled.div` + justify-content: center; +` + +interface UserSelectProps { + users: SerializedUser[] + showClearOption: boolean + onSelect: (user?: SerializedUser) => void +} + +const UserSelect = ({ users, onSelect, showClearOption }: UserSelectProps) => { + const [query, setQuery] = useState('') + + const options = useMemo(() => { + const userOptions = users + .filter((user) => user.displayName.includes(query)) + .map((user) => { + return { + label: user.displayName, + icon: ( + + ), + onClick: () => onSelect(user), + } + }) + + return userOptions + }, [onSelect, query, users]) + + return ( + + ), + onClick: () => onSelect(undefined), + }, + ] + : options + } + /> + ) +} + +export default BoostUserProp diff --git a/src/cloud/components/Blocks/props/CheckboxProp.tsx b/src/cloud/components/Blocks/props/CheckboxProp.tsx new file mode 100644 index 0000000000..f11bfcaad7 --- /dev/null +++ b/src/cloud/components/Blocks/props/CheckboxProp.tsx @@ -0,0 +1,17 @@ +import React from 'react' +import { parseBoolean } from '../../../lib/utils/string' +import { BlockPropertyProps } from './types' +import Checkbox from '../../../../design/components/molecules/Form/atoms/FormCheckbox' + +const CheckboxProp = ({ value, onUpdate }: BlockPropertyProps) => { + return ( +
+ onUpdate(parseBoolean(value) === true ? 'false' : 'true')} + checked={parseBoolean(value)} + /> +
+ ) +} + +export default CheckboxProp diff --git a/src/cloud/components/Blocks/props/DataTypeMenu.tsx b/src/cloud/components/Blocks/props/DataTypeMenu.tsx new file mode 100644 index 0000000000..6022f27ec4 --- /dev/null +++ b/src/cloud/components/Blocks/props/DataTypeMenu.tsx @@ -0,0 +1,107 @@ +import { + mdiAccountCircleOutline, + mdiCalendarOutline, + mdiCheckboxMarkedOutline, + mdiLink, + mdiNumeric, + mdiText, +} from '@mdi/js' +import React from 'react' +import MetadataContainerRow from '../../../../design/components/organisms/MetadataContainer/molecules/MetadataContainerRow' +import { PropType } from '../../../lib/blocks/props' + +interface DataTypeMenuProps { + onSelect: (type: PropType) => void +} + +const DataTypeMenu = ({ onSelect }: DataTypeMenuProps) => { + return ( + <> + onSelect('text'), + iconPath: mdiText, + }, + }} + /> + onSelect('number'), + iconPath: mdiNumeric, + }, + }} + /> + onSelect('date'), + iconPath: mdiCalendarOutline, + }, + }} + /> + onSelect('user'), + iconPath: mdiAccountCircleOutline, + }, + }} + /> + onSelect('url'), + iconPath: mdiLink, + }, + }} + /> + onSelect('checkbox'), + iconPath: mdiCheckboxMarkedOutline, + }, + }} + /> + + ) +} + +export default DataTypeMenu + +export function getBlockPropertyIconByType(type: string) { + switch (type) { + case 'number': + return mdiNumeric + case 'date': + return mdiCalendarOutline + case 'user': + return mdiAccountCircleOutline + case 'url': + return mdiLink + case 'checkbox': + return mdiCheckboxMarkedOutline + default: + return mdiText + } +} diff --git a/src/cloud/components/Blocks/props/DateProp.tsx b/src/cloud/components/Blocks/props/DateProp.tsx new file mode 100644 index 0000000000..dc60d16b82 --- /dev/null +++ b/src/cloud/components/Blocks/props/DateProp.tsx @@ -0,0 +1,86 @@ +import { mdiCalendarMonthOutline, mdiClose } from '@mdi/js' +import { isValid } from 'date-fns' +import React, { useCallback, useMemo } from 'react' +import DatePicker, { ReactDatePickerProps } from 'react-datepicker' +import styled from '../../../../design/lib/styled' +import DocPropertyValueButton from '../../DocProperties/DocPropertyValueButton' +import { BlockPropertyProps } from './types' +import { format as formatDate } from 'date-fns' +import Button from '../../../../design/components/atoms/Button' +import { useI18n } from '../../../lib/hooks/useI18n' +import { lngKeys } from '../../../lib/i18n/types' +import Portal from '../../../../design/components/atoms/Portal' + +const DateProp = ({ + value, + onUpdate, + currentUserIsCoreMember, +}: BlockPropertyProps) => { + const { translate } = useI18n() + const date = useMemo(() => { + const parsed = new Date(value) + return isValid(parsed) ? parsed : null + }, [value]) + + const onChange: ReactDatePickerProps['onChange'] = useCallback( + (date) => { + if (date == null) { + onUpdate('') + } else if (Array.isArray(date)) { + onUpdate(date[0].toISOString()) + } else { + onUpdate(date.toISOString()) + } + }, + [onUpdate] + ) + + return ( + + + {date != null + ? formatDate(date, 'MMM dd, yyyy') + : translate(lngKeys.DueDate)} + + } + /> + {value.trim() !== '' && ( + +
+ + +
+ {block.children.map((child) => { + return ( + + ) + })} +
+ + openModal( + , + { + title: 'Add a block', + showCloseIcon: true, + } + ), + }, + ]} + /> + + ) +} + +const StyledGithubIssueView = styled.div` + h1 { + margin: 0; + } + + .github-issue__body { + padding: ${({ theme }) => theme.sizes.spaces.xsm}px + ${({ theme }) => theme.sizes.spaces.sm}px; + background: ${({ theme }) => theme.colors.background.secondary}; + white-space: break-spaces; + display: block; + } + + .github-issue__view__title .icon { + margin-right: ${({ theme }) => theme.sizes.spaces.sm}px; + } + + .block__layout + .block__layout { + margin-top: ${({ theme }) => theme.sizes.spaces.sm}px; + } + + .github-issue__view__info { + margin-top: ${({ theme }) => theme.sizes.spaces.md}px; + + .text-cell__controls { + right: initial; + left: 100%; + } + } +` + +export default GithubIssueView diff --git a/src/cloud/components/Blocks/views/Markdown.tsx b/src/cloud/components/Blocks/views/Markdown.tsx new file mode 100644 index 0000000000..6856e63172 --- /dev/null +++ b/src/cloud/components/Blocks/views/Markdown.tsx @@ -0,0 +1,196 @@ +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 './' +import MarkdownPreview from '../../MarkdownView' +import { CodemirrorBinding } from 'y-codemirror' +import Spinner from '../../../../design/components/atoms/Spinner' +import { mdiEyeOutline, mdiPencil, mdiTrashCanOutline } from '@mdi/js' +import { getBlockDomId } from '../../../lib/blocks/dom' +import cc from 'classcat' +import BlockLayout from '../BlockLayout' +import { blockEventEmitter, BlockEventDetails } from '../../../lib/utils/events' + +const MarkdownView = ({ + block, + realtime, + actions, + currentUserIsCoreMember, +}: 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(() => { + 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 + 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')) + }, []) + + useEffect(() => { + realtime.on('sync', setSynced) + return () => realtime.off('sync', setSynced) + }, [realtime]) + + useEffect(() => { + if (mode === 'editor' && editorRef.current != null) { + editorRef.current.refresh() + editorRef.current.focus() + } + }, [mode]) + + useEffect(() => { + const handler = ({ detail }: CustomEvent) => { + if (detail.blockId !== block.id || detail.blockType !== block.type) { + return + } + + switch (detail.event) { + case 'creation': + setMode('editor') + return + default: + return + } + } + blockEventEmitter.listen(handler) + return () => blockEventEmitter.unlisten(handler) + }, [block]) + + return ( + actions.remove(block), + }, + ] + : undefined + } + > + setMode('editor')} + > + {!synced ? ( + + ) : ( + <> +
+ +
+ + + )} +
+
+ ) +} + +const StyledMarkdownView = styled.div` + position: relative; + min-height: 20px; + + & .block__markdown--toolbar { + display: none; + position: absolute; + right: 0; + top: 0; + z-index: 1000; + padding: ${({ theme }) => theme.sizes.spaces.sm}px; + + & svg { + cursor: pointer; + } + } + + & > .block__markdown--preview { + padding: 0; + } + + &.block__markdown--mode-editor { + & > .block__markdown--preview { + display: none; + } + } + + &.block__markdown--mode-view { + & > .block__markdown--editor { + display: none; + } + } +` + +function resolveKeyMap(keyMap: CodeMirrorKeyMap) { + switch (keyMap) { + case 'vim': + return 'vim' + case 'default': + default: + return 'sublime' + } +} +export default MarkdownView diff --git a/src/cloud/components/Blocks/views/Table/ColumnSettings.tsx b/src/cloud/components/Blocks/views/Table/ColumnSettings.tsx new file mode 100644 index 0000000000..fe0135c6d0 --- /dev/null +++ b/src/cloud/components/Blocks/views/Table/ColumnSettings.tsx @@ -0,0 +1,169 @@ +import { + mdiArrowLeftBold, + mdiArrowRightBold, + mdiTrashCanOutline, +} from '@mdi/js' +import React, { useCallback, useEffect, useMemo, useState } from 'react' +import FormInput from '../../../../../design/components/molecules/Form/atoms/FormInput' +import MetadataContainer from '../../../../../design/components/organisms/MetadataContainer' +import MetadataContainerBreak from '../../../../../design/components/organisms/MetadataContainer/atoms/MetadataContainerBreak' +import MetadataContainerRow from '../../../../../design/components/organisms/MetadataContainer/molecules/MetadataContainerRow' +import { useModal } from '../../../../../design/lib/stores/modal' +import styled from '../../../../../design/lib/styled' +import { PropType } from '../../../../lib/blocks/props' +import { + Column, + getColType, + isPropCol, + PropCol, +} from '../../../../lib/blocks/table' +import { capitalize } from '../../../../lib/utils/string' +import DataTypeMenu, { + getBlockPropertyIconByType, +} from '../../props/DataTypeMenu' + +interface ColumnSettingsProps { + col: Column + setColName: (col: Column, name: string) => void + setColDataType: (col: PropCol, type: PropType) => void + deleteCol: (col: Column) => void + moveColumn: (col: Column, direction: 'left' | 'right') => void +} + +const ColumnSettings = ({ + col, + setColName, + setColDataType, + deleteCol, + moveColumn, +}: ColumnSettingsProps) => { + const [colInternal, setColInternal] = useState(col) + const { openContextModal, closeAllModals } = useModal() + + const onChange: React.ChangeEventHandler = useCallback( + (ev) => { + const newName = ev.target.value + setColName(colInternal, newName) + setColInternal((col) => { + return { ...col, name: newName } + }) + }, + [colInternal, setColName] + ) + + useEffect(() => { + setColInternal(col) + }, [col]) + + const openTypeSelector: React.MouseEventHandler = useCallback( + (ev) => { + if (isPropCol(colInternal)) { + openContextModal( + ev, + + { + setColDataType(colInternal, type) + setColInternal({ ...colInternal, type }) + closeAllModals() + }} + /> + , + { width: 300, keepAll: true } + ) + } + }, + [colInternal, closeAllModals, openContextModal, setColDataType] + ) + + const dataType = useMemo(() => { + return getColType(colInternal) + }, [colInternal]) + + return ( + + + + { + if (ev.key === 'Enter' && !(ev.ctrlKey || ev.metaKey)) { + ev.preventDefault() + ev.stopPropagation() + closeAllModals() + } + }} + /> + {dataType !== 'prop' && ( + <> + + + + )} + + moveColumn(col, 'left'), + id: 'column-setting-moveleft', + }, + }} + /> + moveColumn(col, 'right'), + id: 'column-setting-moveright', + }, + }} + /> + deleteCol(col), + id: 'column-setting-delete', + }, + }} + /> + + + ) +} + +export default ColumnSettings + +const Container = styled.div` + & .table__column__settings__type { + position: relative; + & > .table__column__settings__type__wrapper { + position: absolute; + left: 100%; + } + } +` diff --git a/src/cloud/components/Blocks/views/Table/InteractableCell.tsx b/src/cloud/components/Blocks/views/Table/InteractableCell.tsx new file mode 100644 index 0000000000..17cc5df0f4 --- /dev/null +++ b/src/cloud/components/Blocks/views/Table/InteractableCell.tsx @@ -0,0 +1,44 @@ +import React, { PropsWithChildren } from 'react' +import styled from '../../../../../design/lib/styled' +import { ControlButtonProps } from '../../../../../design/lib/types' +import cc from 'classcat' + +interface InteractableCellProps { + disabled: boolean + className?: string + hoverControls?: ControlButtonProps[] + onClick?: (ev: React.MouseEvent) => void +} + +const InteractableCell = ({ + disabled, + children, + onClick, + className, +}: PropsWithChildren) => { + return ( + + {children} + + ) +} + +const Container = styled.div` + position: relative; + + &:not(.interactable-cell--disabled) { + cursor: pointer; + &:hover { + background: ${({ theme }) => theme.colors.background.secondary}; + } + } +` + +export default InteractableCell diff --git a/src/cloud/components/Blocks/views/Table/TableSettings.tsx b/src/cloud/components/Blocks/views/Table/TableSettings.tsx new file mode 100644 index 0000000000..bddf0ce8f9 --- /dev/null +++ b/src/cloud/components/Blocks/views/Table/TableSettings.tsx @@ -0,0 +1,117 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react' +import Switch from '../../../../../design/components/atoms/Switch' +import MetadataContainer from '../../../../../design/components/organisms/MetadataContainer' +import MetadataContainerBreak from '../../../../../design/components/organisms/MetadataContainer/atoms/MetadataContainerBreak' +import MetadataContainerRow from '../../../../../design/components/organisms/MetadataContainer/molecules/MetadataContainerRow' +import styled from '../../../../../design/lib/styled' +import { PropType } from '../../../../lib/blocks/props' +import { + Column, + getDataPropColProp, + isDataPropCol, + makeDataPropCol, + makePropCol, +} from '../../../../lib/blocks/table' +import { capitalize } from '../../../../lib/utils/string' +import DataTypeMenu from '../../props/DataTypeMenu' + +interface TableSettingsProps { + columns: Column[] + addColumn: (col: Column, shouldClose?: boolean) => void + deleteColumn: (col: Column, shouldClose?: boolean) => void + subscribe: (observer: (cols: Column[]) => void) => () => void +} + +const GITHUB_PROPS = [ + 'owner', + 'org', + 'repo', + 'issue_number', + 'body', + 'creator', + 'assignees', + 'state', + 'milestone', + 'labels', + 'pull_request', +] as const +const TableSettings = ({ + columns, + addColumn, + deleteColumn, + subscribe, +}: TableSettingsProps) => { + const [internal, setInternal] = useState(columns) + + useEffect(() => { + return subscribe(setInternal) + }, [subscribe]) + + const activeProps = useMemo(() => { + return new Map( + internal + .filter(isDataPropCol) + .map((prop) => [getDataPropColProp(prop), prop]) + ) + }, [internal]) + + const toggleProp = useCallback( + (prop: string) => { + const propKey = activeProps.get(prop) + if (propKey != null) { + deleteColumn(propKey) + } else { + addColumn(makeDataPropCol(capitalize(prop), prop)) + } + }, + [addColumn, deleteColumn, activeProps] + ) + + const insertColumn = useCallback( + (type: PropType) => { + addColumn(makePropCol(capitalize(type), type), true) + }, + [addColumn] + ) + + return ( + + + + {GITHUB_PROPS.map((prop) => { + return ( + toggleProp(prop)} + id={`github-prop-${prop}`} + /> + ), + }} + /> + ) + })} + + + + + + ) +} + +export default TableSettings + +const Container = styled.div` + & .table__settings__toggle { + & .metadata__content { + display: flex; + justify-content: flex-end; + } + } +` diff --git a/src/cloud/components/Blocks/views/Table/index.tsx b/src/cloud/components/Blocks/views/Table/index.tsx new file mode 100644 index 0000000000..099f02d918 --- /dev/null +++ b/src/cloud/components/Blocks/views/Table/index.tsx @@ -0,0 +1,606 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react' +import { ViewProps } from '../' +import { + mdiCog, + mdiDownloadOutline, + mdiTrashCan, + mdiTrashCanOutline, +} from '@mdi/js' +import GithubIssueForm from '../../forms/GithubIssueForm' +import { + Column, + getColumnName, + getDataPropColProp, + isDataPropCol, + getDataColumnIcon, + uniqueIdentifier, + toPropKey, + getColType, +} from '../../../../lib/blocks/table' +import GitHubAssigneesData from '../../data/GithubAssigneesData' +import GithubStatusData from '../../data/GithubStatusData' +import GithubLabelsData from '../../data/GithubLabelsData' +import HyperlinkProp from '../../props/HyperlinkProp' +import TableSettings from './TableSettings' +import ColumnSettings from './ColumnSettings' +import { Block, TableBlock } from '../../../../api/blocks' +import { useModal } from '../../../../../design/lib/stores/modal' +import Icon from '../../../../../design/components/atoms/Icon' +import styled from '../../../../../design/lib/styled' +import { StyledUserIcon } from '../../../UserIcon' +import { BlockDataProps } from '../../data/types' +import { useBlockTable } from '../../../../lib/hooks/useBlockTable' +import BlockProp from '../../props' +import Scroller from '../../../../../design/components/atoms/Scroller' +import { getBlockDomId } from '../../../../lib/blocks/dom' +import Flexbox from '../../../../../design/components/atoms/Flexbox' +import BlockLayout from '../../BlockLayout' +import FormInput from '../../../../../design/components/molecules/Form/atoms/FormInput' +import { useDebounce } from 'react-use' +import { blockTitle } from '../../../../lib/utils/blocks' +import BlockIcon from '../../BlockIcon' +import NavigationItem from '../../../../../design/components/molecules/Navigation/NavigationItem' +import { + BlockEventDetails, + blockEventEmitter, +} from '../../../../lib/utils/events' + +type GithubCellProps = BlockDataProps +interface TableViewProps extends ViewProps { + setCurrentBlock: React.Dispatch> +} + +const TableView = ({ + block, + actions, + realtime, + currentUserIsCoreMember, + sendingMap, + setCurrentBlock, +}: TableViewProps) => { + const { openModal, openContextModal, closeAllModals } = useModal() + const { state, actions: tableActions } = useBlockTable(block, realtime.doc) + const [tableTitle, setTableTitle] = useState(block.name || '') + const tableRef = useRef(block.id) + const titleInputRef = useRef(null) + + const subscriptionsRef = useRef void>>(new Set()) + useEffect(() => { + for (const subscription of subscriptionsRef.current) { + subscription(state.columns) + } + }, [state.columns]) + + const tableActionsRef = useRef(tableActions) + useEffect(() => { + tableActionsRef.current = tableActions + }, [tableActions]) + + const openTableSettings: React.MouseEventHandler = useCallback( + (ev) => { + openContextModal( + ev, + { + subscriptionsRef.current.add(fn) + return () => subscriptionsRef.current.delete(fn) + }} + addColumn={(col, shouldClose) => { + tableActionsRef.current.addColumn(col) + if (shouldClose) { + closeAllModals() + } + }} + deleteColumn={(col, shouldClose) => { + tableActionsRef.current.deleteColumn(col) + if (shouldClose) { + closeAllModals() + } + }} + />, + { alignment: 'bottom-right' } + ) + }, + [openContextModal, closeAllModals, state.columns] + ) + + const openColumnSettings = useCallback( + (ev: React.MouseEvent, col: Column) => { + openContextModal( + ev, + + tableActionsRef.current.renameColumn(col, name) + } + setColDataType={(col, type) => + tableActionsRef.current.setColumnType(col, type) + } + deleteCol={(col) => { + tableActionsRef.current.deleteColumn(col) + closeAllModals() + }} + moveColumn={(col, dir) => + tableActionsRef.current.moveColumn(col, dir) + } + />, + { width: 220 } + ) + }, + [closeAllModals, openContextModal] + ) + + const updateIssueBlock = useCallback( + async (block: Block) => { + await actions.update(block) + }, + [actions] + ) + + const importIssues = useCallback(() => { + openModal( + { + await actions.create(issueBlock, block) + return closeAllModals() + }} + />, + { + width: 'large', + showCloseIcon: true, + } + ) + }, [openModal, actions, block, closeAllModals]) + + const onTableNameChange: React.ChangeEventHandler = useCallback( + (e) => { + readyToBeSentRef.current = true + setTableTitle(e.target.value) + }, + [] + ) + const readyToBeSentRef = useRef(false) + const [, cancel] = useDebounce( + async () => { + if (readyToBeSentRef.current) { + await actions.update({ + id: block.id, + type: block.type, + name: tableTitle, + }) + readyToBeSentRef.current = false + } else { + cancel() + } + }, + 1000, + [tableTitle] + ) + + useEffect(() => { + if (tableRef.current !== block.id) { + tableRef.current = block.id + setTableTitle(block.name) + } + }, [block]) + + useEffect(() => { + const handler = ({ detail }: CustomEvent) => { + if (detail.blockId !== block.id || detail.blockType !== block.type) { + return + } + + switch (detail.event) { + case 'creation': + titleInputRef.current?.focus() + return + default: + return + } + } + blockEventEmitter.listen(handler) + return () => blockEventEmitter.unlisten(handler) + }, [block]) + + const anchorId = `block__${block.id}__table` + return ( + actions.remove(block), + }, + ] + : undefined + } + > + + +
+
+ + + + + + {state.columns.map((col) => ( + + ))} + + + + {block.children.map((child) => { + return ( + + + {state.columns.map((col) => ( + + ))} + + ) + })} + +
Title + +
+ + , + }} + labelClick={() => setCurrentBlock(child)} + controls={[ + { + icon: mdiTrashCan, + onClick: () => actions.remove(child), + disabled: sendingMap.has(child.id), + spinning: + sendingMap.get(child.id) === 'delete-block', + }, + ]} + className='table__title__tree' + /> + + + {isDataPropCol(col) ? ( + + updateIssueBlock({ ...child, data }) + } + /> + ) : ( + + tableActions.setCell(child.id, col, val) + } + /> + )} +
+
+
+ + + + + ) +} + +const GithubCell = ({ + prop, + data, + onUpdate, +}: GithubCellProps & { prop: string }) => { + switch (prop) { + case 'assignees': + return + case 'owner': + return data.repository != null ? ( +
+ + {data.repository.owner.login[0]} + +
+ ) : null + case 'creator': + return data.user != null ? ( +
+ + {data.user.login[0]} + +
+ ) : null + case 'state': + return + case 'labels': + return + case 'pull_request': + const url = data?.pull_request?.html_url || '' + return + case 'org': + return data.repository != null && data.repository.organization != null ? ( + + ) : null + case 'repo': + const issueUrl = data.html_url || '' + const repoRegex = new RegExp( + /(^https:\/\/github.com\/(?:([^\/]+)\/)+)(?:(?:issues|pull\/(?:[0-9]+)))$/, + 'gi' + ) + const matches = repoRegex.exec(issueUrl) + return ( + + ) + case 'issue_number': + const issueURL = data?.html_url || '' + const issueRegex = new RegExp( + /^https:\/\/github.com\/([^\/]+\/)+issues\/([0-9]+)$/, + 'gi' + ) + if (issueRegex.test(issueURL)) { + return + } else { + return null + } + case 'body': + return
{data.body}
+ case 'milestone': + return ( + + ) + default: + return null + } +} + +function getPRNumFromUrl(url: string) { + const num = url.split('/').pop() + return num != null && num !== '' ? `#${num}` : url +} + +const StyledTableView = styled.div` + position: relative; + + .table__title__tree { + .navigation__item__label__ellipsis { + max-width: 300px; + } + + .navigation__item__controls { + display: block; + opacity: 0; + } + + .navigation__item__wrapper:hover .navigation__item__controls { + opacity: 1; + } + } + + .table__title__tree:not(.block__editor__nav--tree) + > .block__editor__nav--item:first-of-type::before { + display: none; + } + + .block__table__title { + width: 100%; + border: 0; + font-size: ${({ theme }) => theme.sizes.fonts.l}px; + font-weight: 600; + } + + .block__table__cell--shrinked { + width: 1%; + white-space: nowrap; + } + + & .block__table__view--interactable { + cursor: pointer; + + &:active { + background-color: ${({ theme }) => theme.colors.background.tertiary}; + } + + &:focus { + background-color: ${({ theme }) => theme.colors.background.quaternary}; + } + + &:hover { + color: ${({ theme }) => theme.colors.text.primary}; + background-color: ${({ theme }) => theme.colors.background.secondary}; + } + } + + & .block__table__view__wrapper { + width: 100%; + overflow-x: auto; + } + + & table { + width: 100%; + text-align: left; + border-collapse: separate; + position: relative; + z-index: 0; + } + + & td:first-child, + th:first-child { + width: 400px; + position: sticky; + top: 0; + left: 0; + z-index: 1; + } + + & td > div { + height: 100%; + width: 100%; + padding: ${({ theme }) => theme.sizes.spaces.sm}px; + display: flex; + align-items: center; + } + + & .block__table__view--no-borders { + border: none; + } + + & th { + border-top: 1px solid ${({ theme }) => theme.colors.border.main}; + color: ${({ theme }) => theme.colors.text.subtle}; + + &:not(.block__table__view--interactable), + > button { + padding: ${({ theme }) => theme.sizes.spaces.sm}px; + } + + > button { + background: none; + color: ${({ theme }) => theme.colors.text.subtle}; + border: 0; + width: 100%; + height: 100%; + } + } + + .block__table__header__icon { + margin-right: ${({ theme }) => theme.sizes.spaces.sm}px; + flex: 0 0 auto; + } + + & td { + min-width: 130px; + min-height: 20px; + height: auto; + } + + td, + th { + position: relative; + } + + & td, + th { + width: 130px; + border-right: 1px solid ${({ theme }) => theme.colors.border.main}; + border-bottom: 1px solid ${({ theme }) => theme.colors.border.main}; + } + + & .block__table__view__import { + width: 100%; + cursor: pointer; + display: flex; + align-items: center; + border-bottom: 1px solid ${({ theme }) => theme.colors.border.main}; + color: ${({ theme }) => theme.colors.text.subtle}; + padding: ${({ theme }) => theme.sizes.spaces.sm}px + ${({ theme }) => theme.sizes.spaces.df}px; + + background: ${({ theme }) => theme.colors.background.primary}; + &:focus { + background: ${({ theme }) => theme.colors.background.quaternary}; + } + &:hover { + background: ${({ theme }) => theme.colors.background.tertiary}; + } + } + + & .block__table__view__child__label { + position: relative; + padding-left: 18px; + 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; + } + } +` + +export function getTableBlockInputId(block: Block) { + return `${block.id}-title` +} + +export default TableView diff --git a/src/cloud/components/Blocks/views/index.tsx b/src/cloud/components/Blocks/views/index.tsx new file mode 100644 index 0000000000..cf89ea24c4 --- /dev/null +++ b/src/cloud/components/Blocks/views/index.tsx @@ -0,0 +1,115 @@ +import React from 'react' +import { WebsocketProvider } from 'y-websocket' +import { Block } from '../../../api/blocks' +import { BlockActions } from '../../../lib/hooks/useDocBlocks' +import Markdown from './Markdown' +import { Canvas } from '../BlockContent' +import Container from './Container' +import Embed from './Embed' +import Table from './Table' +import GithubIssueView from './GithubIssue' + +export interface ViewProps { + isRootBlock?: boolean + block: T + actions: BlockActions + canvas: Canvas + realtime: WebsocketProvider + isChild?: boolean + setCurrentBlock: React.Dispatch> + scrollToElement: (elem: HTMLElement | null) => void + currentUserIsCoreMember: boolean + sendingMap: Map +} +export const BlockView = ({ + isRootBlock, + block, + actions, + canvas, + realtime, + isChild, + currentUserIsCoreMember, + sendingMap, + scrollToElement, + setCurrentBlock, +}: ViewProps) => { + switch (block.type) { + case 'container': + return ( + + ) + case 'embed': + return ( + + ) + case 'markdown': + return ( + + ) + case 'table': + return ( + + ) + case 'github.issue': + return ( + + ) + default: + return ( +
+ Block of type ${(block as any).type || 'unknown'} is unsupported +
+ ) + } +} diff --git a/src/cloud/components/ContentManager/index.tsx b/src/cloud/components/ContentManager/index.tsx index 2d6855d683..6fdc3f6a68 100644 --- a/src/cloud/components/ContentManager/index.tsx +++ b/src/cloud/components/ContentManager/index.tsx @@ -31,7 +31,7 @@ import Flexbox from '../../../design/components/atoms/Flexbox' import ContentManagerStatusFilter from './ContentManagerStatusFilter' import { useCloudDnd } from '../../lib/hooks/sidebar/useCloudDnd' import { DraggedTo } from '../../../design/lib/dnd' -import VerticalScroller from '../../../design/components/atoms/VerticalScroller' +import Scroller from '../../../design/components/atoms/Scroller' import { FormSelectOption } from '../../../design/components/molecules/Form/atoms/FormSelect' import Checkbox from '../../../design/components/molecules/Form/atoms/FormCheckbox' @@ -261,7 +261,7 @@ const ContentManager = ({ return ( - + {folders != null ? (
@@ -391,7 +391,7 @@ const ContentManager = ({ )} - + {currentUserIsCoreMember && ( restoreRevision?: (revision: SerializedRevision) => void + isCanvas?: boolean } export function DocContextMenuActions({ @@ -70,6 +71,7 @@ export function DocContextMenuActions({ currentUserIsCoreMember, editorRef, restoreRevision, + isCanvas, }: DocContextMenuActionsProps) { const { translate } = useI18n() const { sendingMap, toggleDocBookmark, send, updateDoc } = useCloudApi() @@ -219,7 +221,7 @@ export function DocContextMenuActions({ }, }} /> - {currentUserIsCoreMember && ( + {currentUserIsCoreMember && !isCanvas && ( )} - - {subscription == null ? ( - - ) : null} - + {!isCanvas && ( + + {subscription == null ? ( + + ) : null} + + )} )} - - - - + {!isCanvas && ( + <> + + + + + + )} {currentUserIsCoreMember && ( <> diff --git a/src/cloud/components/DocPage/EditorLayout.tsx b/src/cloud/components/DocPage/EditorLayout.tsx index 738f9327c6..3254573e63 100644 --- a/src/cloud/components/DocPage/EditorLayout.tsx +++ b/src/cloud/components/DocPage/EditorLayout.tsx @@ -4,7 +4,7 @@ import { SerializedDocWithBookmark } from '../../interfaces/db/doc' import DocPageHeader from './DocPageHeader' import cc from 'classcat' import { SerializedTeam } from '../../interfaces/db/team' -import VerticalScroller from '../../../design/components/atoms/VerticalScroller' +import Scroller from '../../../design/components/atoms/Scroller' interface EditorLayoutProps { docIsEditable: boolean @@ -25,7 +25,7 @@ const EditorLayout = ({ - +
{children}
-
+
) } diff --git a/src/cloud/components/DocPage/NewDocContextMenu.tsx b/src/cloud/components/DocPage/NewDocContextMenu.tsx index ce6ef4533a..102aaf7dd0 100644 --- a/src/cloud/components/DocPage/NewDocContextMenu.tsx +++ b/src/cloud/components/DocPage/NewDocContextMenu.tsx @@ -28,24 +28,26 @@ import DocContextMenuActions from './DocContextMenuActions' interface DocContextMenuProps { currentDoc: SerializedDocWithBookmark - contributors: SerializedUser[] - backLinks: SerializedDoc[] + contributors?: SerializedUser[] + backLinks?: SerializedDoc[] team: SerializedTeam permissions: SerializedUserTeamPermissions[] currentUserIsCoreMember: boolean editorRef?: React.MutableRefObject restoreRevision?: (revision: SerializedRevision) => void + isCanvas?: boolean } const DocContextMenu = ({ team, currentDoc: doc, - contributors, - backLinks, + contributors = [], + backLinks = [], permissions, currentUserIsCoreMember, editorRef, restoreRevision, + isCanvas, }: DocContextMenuProps) => { const [sliceContributors, setSliceContributors] = useState(true) const { docsMap } = useNav() @@ -175,39 +177,40 @@ const DocContextMenu = ({ ), }} /> - - {contributorsState.contributors.map((contributor) => ( - - ))} - - {contributors.length > 0 && ( - <> -
- - - )} - - ), - }} - /> + {!isCanvas && ( + + {contributorsState.contributors.map((contributor) => ( + + ))} + {contributors.length > 0 && ( + <> +
+ + + )} + + ), + }} + /> + )} ) diff --git a/src/cloud/components/DocPage/index.tsx b/src/cloud/components/DocPage/index.tsx index 89ef6c3f5d..5407fe8df8 100644 --- a/src/cloud/components/DocPage/index.tsx +++ b/src/cloud/components/DocPage/index.tsx @@ -21,6 +21,7 @@ import { useRouter } from '../../lib/router' import ColoredBlock from '../../../design/components/atoms/ColoredBlock' import Editor from '../Editor' import ApplicationPage from '../ApplicationPage' +import BlockEditor from '../Blocks/BlockEditor' interface DocPageProps { doc: SerializedDocWithBookmark @@ -170,16 +171,18 @@ const DocPage = ({ ) } - return ( + return currentDoc.rootBlock == null ? ( + ) : ( + ) } diff --git a/src/cloud/components/EventSource.tsx b/src/cloud/components/EventSource.tsx index 8ad4598fe3..e9a78d32e8 100644 --- a/src/cloud/components/EventSource.tsx +++ b/src/cloud/components/EventSource.tsx @@ -20,6 +20,7 @@ import { getUniqueFolderAndDocIdsFromResourcesIds } from '../lib/utils/patterns' import { getAccessToken, useElectron } from '../lib/stores/electron' import { useNotifications } from '../../design/lib/stores/notifications' import { useComments } from '../lib/stores/comments' +import { useBlocks } from '../lib/stores/blocks' interface EventSourceProps { teamId: string @@ -67,6 +68,7 @@ const EventSource = ({ teamId }: EventSourceProps) => { } = useGlobalData() const { commentsEventListener } = useComments() const { notificationsEventListener } = useNotifications() + const { blockEventListener } = useBlocks() const setupEventSource = useCallback( (url: string) => { @@ -452,6 +454,11 @@ const EventSource = ({ teamId }: EventSourceProps) => { case 'notificationViewed': notificationsEventListener(event) break + case 'blockCreated': + case 'blockDeleted': + case 'blockUpdated': + blockEventListener(event) + break } updateAppEventsMap([event.id, event]) } @@ -474,6 +481,7 @@ const EventSource = ({ teamId }: EventSourceProps) => { smartFolderDeleteHandler, updateAppEventsMap, notificationsEventListener, + blockEventListener, ]) return null diff --git a/src/cloud/components/Modal/contents/Doc/RevisionsModal/RevisionModalDetail.tsx b/src/cloud/components/Modal/contents/Doc/RevisionsModal/RevisionModalDetail.tsx index 17bff1e6de..ed85f55bf5 100644 --- a/src/cloud/components/Modal/contents/Doc/RevisionsModal/RevisionModalDetail.tsx +++ b/src/cloud/components/Modal/contents/Doc/RevisionsModal/RevisionModalDetail.tsx @@ -6,7 +6,7 @@ import styled from '../../../../../../design/lib/styled' import { useSettings } from '../../../../../lib/stores/settings' import Flexbox from '../../../../../../design/components/atoms/Flexbox' import CodeMirrorEditor from '../../../../../lib/editor/components/CodeMirrorEditor' -import VerticalScroller from '../../../../../../design/components/atoms/VerticalScroller' +import Scroller from '../../../../../../design/components/atoms/Scroller' import cc from 'classcat' interface RevisionModalDetailProps { @@ -92,14 +92,14 @@ const RevisionModalDetail = ({ }} /> ) : ( - + - + )} ) diff --git a/src/cloud/components/Modal/contents/Doc/RevisionsModal/RevisionModalNavigator.tsx b/src/cloud/components/Modal/contents/Doc/RevisionsModal/RevisionModalNavigator.tsx index 61649a868d..944c29d591 100644 --- a/src/cloud/components/Modal/contents/Doc/RevisionsModal/RevisionModalNavigator.tsx +++ b/src/cloud/components/Modal/contents/Doc/RevisionsModal/RevisionModalNavigator.tsx @@ -12,7 +12,7 @@ import { import { focusFirstChildFromElement } from '../../../../../../design/lib/dom' import Spinner from '../../../../../../design/components/atoms/Spinner' import plur from 'plur' -import VerticalScroller from '../../../../../../design/components/atoms/VerticalScroller' +import Scroller from '../../../../../../design/components/atoms/Scroller' import NavigationItem from '../../../../../../design/components/molecules/Navigation/NavigationItem' import Flexbox from '../../../../../../design/components/atoms/Flexbox' import { format } from 'date-fns' @@ -76,7 +76,7 @@ const RevisionModalNavigator = React.forwardRef<
- + {revisions.map((rev) => ( ))} - + {currentPage < totalPages && ( - ) : ( - - {translate(lngKeys.GeneralEnableVerb)} - - ) - ) : ( - - )} - - )} -
-
-
- Manage access via GitHub{' '} - - here - -
-
- + +

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/cloud/interfaces/db/appEvents.ts b/src/cloud/interfaces/db/appEvents.ts index cc6dd42c39..c4e4486edf 100644 --- a/src/cloud/interfaces/db/appEvents.ts +++ b/src/cloud/interfaces/db/appEvents.ts @@ -35,6 +35,9 @@ export type SseEventType = | 'smartFolderDelete' | 'notificationCreated' | 'notificationViewed' + | 'blockCreated' + | 'blockUpdated' + | 'blockDeleted' export interface SerializableAppEventProps { id: string 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 { diff --git a/src/cloud/interfaces/db/team.ts b/src/cloud/interfaces/db/team.ts index 3a211382d6..405c1094b3 100644 --- a/src/cloud/interfaces/db/team.ts +++ b/src/cloud/interfaces/db/team.ts @@ -27,4 +27,5 @@ export type SerializedTeam = SerializedUnserializableTeamProps & export interface TeamOnboardingState { import?: boolean settings?: boolean + blocksBeta?: boolean } diff --git a/src/cloud/lib/blocks/dom.ts b/src/cloud/lib/blocks/dom.ts new file mode 100644 index 0000000000..6eff5b0e57 --- /dev/null +++ b/src/cloud/lib/blocks/dom.ts @@ -0,0 +1,21 @@ +import { Block } from '../../api/blocks' +import { blockEventEmitter } from '../utils/events' +import { sleep } from '../utils/sleep' + +export function getBlockDomId(block: Block) { + return `${block.id}--block-${block.type}` +} + +export async function domBlockCreationHandler( + scrollToElement: (elem: HTMLElement | null) => void, + createdBlock: Block +) { + await sleep(100) //rendering delay + const blockElem = document.getElementById(getBlockDomId(createdBlock)) + scrollToElement(blockElem) + blockEventEmitter.dispatch({ + blockId: createdBlock.id, + blockType: createdBlock.type, + event: 'creation', + }) +} diff --git a/src/cloud/lib/blocks/props.ts b/src/cloud/lib/blocks/props.ts new file mode 100644 index 0000000000..eb4f2fea00 --- /dev/null +++ b/src/cloud/lib/blocks/props.ts @@ -0,0 +1,37 @@ +const PROP_TYPES = [ + 'text', + 'number', + 'date', + 'url', + 'checkbox', + 'user', +] as const + +export type PropType = typeof PROP_TYPES[number] +// eslint-disable-next-line prettier/prettier +export type PropKey = `${string}:${PropType}` + +export function isPropKey(k: any): k is PropKey { + if (typeof k !== 'string') { + return false + } + + const [_key, type, ...rest] = k.split(':') + if (rest.length !== 0) { + return false + } + + return PROP_TYPES.includes(type as any) +} + +export function makePropKey(name: string, type: PropType): PropKey { + return `${name}:${type}` +} + +export function getPropName(type: PropKey): string { + return type.split(':')[0] +} + +export function getPropType(type: PropKey): PropType { + return type.split(':')[1] as PropType +} diff --git a/src/cloud/lib/blocks/table.ts b/src/cloud/lib/blocks/table.ts new file mode 100644 index 0000000000..c6404a0f08 --- /dev/null +++ b/src/cloud/lib/blocks/table.ts @@ -0,0 +1,262 @@ +import { Array as YArray, Map as YMap, Doc as YDoc } from 'yjs' +import { makePropKey, PropType } from './props' +import { + mdiAccountCircleOutline, + mdiCalendarOutline, + mdiCheckboxMarkedOutline, + mdiGithub, + mdiLink, + mdiNumeric, + mdiText, +} from '@mdi/js' +import { v4 as uuid } from 'uuid' + +interface DataPropCol { + prop: string + name: string +} + +export interface PropCol { + id: string + name: string + type: PropType +} + +export type Column = PropCol | DataPropCol + +export type YTable = YArray + +export function getYTable(id: string, doc: YDoc): YTable { + return sanitize(doc.getArray(id)) +} + +export function addColumn(column: Column, table: YTable): YTable { + table.doc?.transact(() => { + if (!contains(column, table)) { + table.push([sanitizeColumn(column)]) + } + }) + return table +} + +export function moveColumn( + target: number | 'left' | 'right', + column: Column, + table: YTable +): YTable { + table.doc?.transact(() => { + let from = -1 + for (let i = 0; i < table.length; i++) { + if (eq(table.get(i), column)) { + from = i + } + } + + if (from < 0) { + return + } + + const normalizedTarget = + typeof target === 'string' + ? target === 'left' + ? from - 1 + : from + 1 + : target + + if (normalizedTarget < 0 || normalizedTarget === from) { + return + } + + table.delete(from) + table.insert(Math.min(normalizedTarget, table.length), [column]) + }) + return table +} + +export function deleteColumn( + column: Column, + table: YTable, + rows: YMap[] = [] +): YTable { + table.doc?.transact(() => { + for (let i = 0; i < table.length; i++) { + if (eq(table.get(i), column)) { + table.delete(i) + } + } + if (isPropCol(column)) { + for (const row of rows) { + row.delete(toPropKey(column)) + } + } + }) + return table +} + +export function renameColumn( + column: Column, + name: string, + table: YTable, + rows: YMap[] = [] +) { + table.doc?.transact(() => { + const newCol = { ...column, name } + + if (isPropCol(column)) { + const key = toPropKey(column) + const newKey = toPropKey(newCol as PropCol) + for (const row of rows) { + row.set(newKey, row.get(key) || '') + row.delete(key) + } + } + + updateColumn(newCol, table) + }) + return table +} + +export function setColumnType( + column: PropCol, + type: PropType, + table: YTable, + rows: YMap[] = [] +): YTable { + table.doc?.transact(() => { + const newCol = { ...column, type } + const newKey = toPropKey(newCol) + const oldKey = toPropKey(column) + for (const row of rows) { + row.set(newKey, '') + row.delete(oldKey) + } + + updateColumn(newCol, table) + }) + + return table +} + +export function updateColumn(column: Column, yarr: YArray) { + for (let i = 0; i < yarr.length; i++) { + if (eq(yarr.get(i), column)) { + yarr.delete(i) + yarr.insert(i, [column]) + } + } + return yarr +} + +export function sanitize(columns: YTable) { + for (let i = columns.length - 1; i >= 0; i--) { + if (!isColumn(columns.get(i))) { + const item = columns.get(1) + if (item instanceof YArray) { + console.log(item.toArray()) + } + console.log('removing', item) + columns.delete(i) + } + } + + return columns +} + +function sanitizeColumn(col: Column): Column { + return isDataPropCol(col) + ? makeDataPropCol(col.name, col.prop) + : { id: col.id, name: col.name, type: col.type } +} + +export function toArray(ytable: YTable): Column[] { + return ytable.toArray() +} + +export function getColumnName(col: Column): string { + return col.name +} + +export function makeDataPropCol(name: string, prop: string): DataPropCol { + return { name, prop } +} + +export function makePropCol(name: string, type: PropType): PropCol { + return { id: uuid(), name, type } +} + +export function isColumn(item: any): item is Column { + return isDataPropCol(item) || isPropCol(item) +} + +export function isPropCol(item: any): item is PropCol { + return ( + item != null && typeof item.id === 'string' && typeof item.name === 'string' + ) +} + +export function isDataPropCol(item: any): item is DataPropCol { + return ( + item != null && + typeof item.prop === 'string' && + typeof item.name === 'string' + ) +} + +export function getDataPropColName(col: DataPropCol): string { + return col.name +} + +export function getDataPropColProp(col: DataPropCol): string { + return col.prop +} + +export function getColType(col: DataPropCol): 'prop' +export function getColType(col: PropCol): PropType +export function getColType(col: Column): 'prop' | PropType +export function getColType(col: any): any { + return isDataPropCol(col) ? 'prop' : col.type +} + +export function toPropKey(col: PropCol) { + return makePropKey(col.name, col.type) +} + +export function eq(col1: Column, col2: Column): boolean { + return isPropCol(col1) + ? isPropCol(col2) && col1.id === col2.id + : isDataPropCol(col2) && col1.prop === col2.prop +} + +function contains(col: Column, yarr: YArray) { + for (const item of yarr) { + if (eq(col, item)) { + return true + } + } + return false +} + +export function uniqueIdentifier(col: Column) { + return isPropCol(col) ? col.id : col.prop +} + +export function getDataColumnIcon(col: Column) { + const type = getColType(col) + switch (type) { + case 'prop': + return mdiGithub + case 'date': + return mdiCalendarOutline + case 'user': + return mdiAccountCircleOutline + case 'url': + return mdiLink + case 'checkbox': + return mdiCheckboxMarkedOutline + case 'number': + return mdiNumeric + case 'text': + default: + return mdiText + } +} diff --git a/src/cloud/lib/hooks/sidebar/useCloudSidebarTree.tsx b/src/cloud/lib/hooks/sidebar/useCloudSidebarTree.tsx index 2af05b3dc3..2624c24756 100644 --- a/src/cloud/lib/hooks/sidebar/useCloudSidebarTree.tsx +++ b/src/cloud/lib/hooks/sidebar/useCloudSidebarTree.tsx @@ -12,6 +12,7 @@ import { mdiStarOutline, mdiTag, mdiTrashCanOutline, + mdiPaletteOutline, } from '@mdi/js' import { FoldingProps } from '../../../../design/components/atoms/FoldingWrapper' import { SidebarDragState } from '../../../../design/lib/dnd' @@ -484,7 +485,8 @@ export function useCloudSidebarTree() { label: getDocTitle(doc, 'Untitled'), bookmarked: doc.bookmarked, emoji: doc.emoji, - defaultIcon: mdiFileDocumentOutline, + defaultIcon: + doc.rootBlock != null ? mdiPaletteOutline : mdiFileDocumentOutline, status: doc.status, hidden: doc.archivedAt != null || diff --git a/src/cloud/lib/hooks/useBlockProps.ts b/src/cloud/lib/hooks/useBlockProps.ts new file mode 100644 index 0000000000..db24b621bd --- /dev/null +++ b/src/cloud/lib/hooks/useBlockProps.ts @@ -0,0 +1,72 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { Doc as YDoc } from 'yjs' +import { YMap } from 'yjs/dist/src/internals' +import { Block } from '../../api/blocks' +import { isPropKey, PropKey } from '../blocks/props' + +interface Actions { + set: (key: PropKey, value: string) => void + delete: (key: PropKey) => void +} + +export function useBlockProps( + block: Block, + realtime: YDoc +): [Record, Actions] { + const ymap = useRef(realtime.getMap(block.id)) + const [state, setState] = useState(() => ymapToPropMap(ymap.current)) + + useEffect(() => { + ymap.current = realtime.getMap(block.id) + setState(ymapToPropMap(ymap.current)) + const observer = () => setState(ymapToPropMap(ymap.current)) + ymap.current.observe(observer) + + return () => ymap.current.unobserve(observer) + }, [block.id, realtime]) + + return [state, ymap.current] +} + +export function useAvaliableBlockPropKeys( + blocks: Block[], + ydoc: YDoc +): Set { + const [keys, setKeys] = useState(() => + getAvailableKeys(blocks.map((block) => ydoc.getMap(block.id))) + ) + const blockIds = useMemo(() => { + return blocks.map((block) => block.id) + }, [blocks]) + + useEffect(() => { + const maps = blockIds.map((id) => ydoc.getMap(id)) + setKeys(getAvailableKeys(maps)) + const cb = () => { + setKeys(getAvailableKeys(maps)) + } + + for (const map of maps) { + map.observe(cb) + } + return () => { + for (const map of maps) { + map.unobserve(cb) + } + } + }, [blockIds, ydoc]) + + return keys +} + +function ymapToPropMap(map: YMap): Record { + return Object.fromEntries( + Array.from(map.entries()).filter(([key]) => isPropKey(key)) + ) +} + +function getAvailableKeys(maps: YMap[]): Set { + return new Set( + maps.flatMap((map) => Array.from(map.keys()).filter(isPropKey)) + ) +} diff --git a/src/cloud/lib/hooks/useBlockTable.ts b/src/cloud/lib/hooks/useBlockTable.ts new file mode 100644 index 0000000000..4b7c9c78e9 --- /dev/null +++ b/src/cloud/lib/hooks/useBlockTable.ts @@ -0,0 +1,210 @@ +import { Doc as YDoc } from 'yjs' +import { useEffect, useMemo, useState } from 'react' +import { TableBlock } from '../../api/blocks' +import { AbstractType, YMap } from 'yjs/dist/src/internals' +import { + getPropName, + getPropType, + isPropKey, + PropKey, + PropType, +} from '../blocks/props' +import { + addColumn, + Column, + deleteColumn, + getYTable, + isPropCol, + makePropCol, + moveColumn, + PropCol, + renameColumn, + setColumnType, + toArray, + toPropKey, + YTable, +} from '../blocks/table' + +interface PlaceholderPropCol extends PropCol { + isPlaceholder: true +} + +export interface Actions { + addColumn: (key: Column | PlaceholderPropCol) => void + deleteColumn: (key: Column | PlaceholderPropCol) => void + renameColumn: (key: Column | PlaceholderPropCol, name: string) => void + moveColumn: ( + key: Column | PlaceholderPropCol, + direction: 'left' | 'right' + ) => void + setColumnType: (key: PropCol | PlaceholderPropCol, type: PropType) => void + setCell: ( + row: string, + col: PropCol | PlaceholderPropCol, + data: string + ) => void +} + +export interface BlockState { + columns: (Column | PlaceholderPropCol)[] + rowData: Map> +} + +export function useBlockTable(block: TableBlock, ydoc: YDoc) { + const ytable = useMemo(() => { + return getYTable(block.id, ydoc) + }, [block.id, ydoc]) + const yrows = useMemo(() => { + return new Map(block.children.map(({ id }) => [id, ydoc.getMap(id)])) + }, [block.children, ydoc]) + const actions = useMemo(() => { + return buildActions(ytable, yrows) + }, [ytable, yrows]) + const [state, setState] = useState(() => { + const rowData = new Map( + Array.from(yrows).map<[string, Record]>(([id, map]) => [ + id, + ymapToPropMap(map), + ]) + ) + return { + columns: mergeAdditional(toArray(ytable), Array.from(rowData.values())), + rowData, + } + }) + + useEffect(() => { + return subscribeDeep(ytable, () => { + setState((prev) => { + return { + ...prev, + columns: mergeAdditional( + toArray(ytable), + Array.from(prev.rowData.values()) + ), + } + }) + }) + }, [ytable]) + + useEffect(() => { + const unsubscribes = Array.from(yrows).map(([id, map]) => + subscribe(map, () => { + setState((prev) => { + const newMap = new Map(prev.rowData) + const newProps = ymapToPropMap(map) + newMap.set(id, newProps) + return { + columns: mergeAdditional( + prev.columns.filter(not(isPlaceholder)), + Array.from(newMap.values()) + ), + rowData: newMap, + } + }) + }) + ) + + return () => { + for (const unsubscribe of unsubscribes) { + unsubscribe() + } + } + }, [yrows]) + + return { + state, + actions, + } +} + +function mergeAdditional( + cols: Column[], + rowData: Record[] +): Column[] { + const tableColsSet = new Set(cols.filter(isPropCol).map(toPropKey)) + + const additional = rowData + .flatMap(keys) + .filter(isPropKey) + .filter((key) => !tableColsSet.has(key)) + .map(propToPlaceholder) + + return cols.concat(additional) +} + +function keys>(record: T): (keyof T)[] { + return Object.keys(record) +} + +function propToPlaceholder(prop: PropKey): PlaceholderPropCol { + return { + ...makePropCol(getPropName(prop), getPropType(prop)), + isPlaceholder: true, + } +} + +function buildActions( + ytable: YTable, + rows: Map> +): Actions { + return { + addColumn: (key) => addColumn(key, ytable), + deleteColumn: (key) => { + deleteColumn(key, ytable, Array.from(rows.values())) + }, + renameColumn: (key, name) => { + if (isPlaceholder(key)) { + addColumn(key, ytable) + } + renameColumn(key, name, ytable, Array.from(rows.values())) + }, + moveColumn: (key, direction) => { + if (isPlaceholder(key)) { + addColumn(key, ytable) + } + moveColumn(direction, key, ytable) + }, + setColumnType: (key, type) => { + if (isPlaceholder(key)) { + addColumn(key, ytable) + } + setColumnType(key, type, ytable, Array.from(rows.values())) + }, + setCell: (row, key, value) => { + if (rows.has(row)) { + rows.get(row)!.set(toPropKey(key), value) + } + }, + } +} + +function isPlaceholder(col: any): col is PlaceholderPropCol { + return col.isPlaceholder != null && col.isPlaceholder === true +} + +function not(fn: (...args: T[]) => boolean) { + return (...args: T[]) => !fn(...args) +} + +function subscribe>( + map: T, + fn: ArgsType[0] +) { + map.observe(fn) + return () => map.unobserve(fn) +} + +function subscribeDeep>( + map: T, + fn: ArgsType[0] +) { + map.observeDeep(fn) + return () => map.unobserveDeep(fn) +} + +function ymapToPropMap(map: YMap): Record { + return Object.fromEntries( + Array.from(map.entries()).filter(([key]) => isPropKey(key)) + ) +} diff --git a/src/cloud/lib/hooks/useBlocksApi.ts b/src/cloud/lib/hooks/useBlocksApi.ts new file mode 100644 index 0000000000..3cf687207d --- /dev/null +++ b/src/cloud/lib/hooks/useBlocksApi.ts @@ -0,0 +1,97 @@ +import { useCallback } from 'react' +import shortid from 'shortid' +import useBulkApi from '../../../design/lib/hooks/useBulkApi' +import { + Block, + BlockCreateRequestBody, + BlockUpdateRequestBody, + createBlock, + deleteBlock, + updateBlock, +} from '../../api/blocks' +import { useBlocks } from '../stores/blocks' + +export interface CreateBlockApiOptions { + afterSuccess?: (block: Block) => void +} +export interface UpdateBlockApiOptions { + afterSuccess?: (block: Block) => void +} + +export interface DeleteBlockApiOptions { + afterSuccess?: () => void +} + +export function useBlocksApi() { + const { sendingMap, send } = useBulkApi() + const { getBlocks } = useBlocks() + + const createBlockApi = useCallback( + async ( + body: BlockCreateRequestBody, + parent: Block, + options: CreateBlockApiOptions, + root: string + ) => { + return send(shortid.generate(), 'create-block', { + api: () => createBlock(body, parent.id), + cb: async (block: Block) => { + await getBlocks(root) + if (options.afterSuccess != null) { + options.afterSuccess(block) + } + }, + }) + }, + [getBlocks, send] + ) + + const deleteBlockApi = useCallback( + async ( + block: Block, + options: { + afterSuccess?: () => void + }, + root: string + ) => { + return send(block.id, 'delete-block', { + api: () => deleteBlock(block.id), + cb: async () => { + await getBlocks(root) + if (options.afterSuccess != null) { + options.afterSuccess() + } + }, + }) + }, + [getBlocks, send] + ) + + const updateBlockApi = useCallback( + async ( + block: BlockUpdateRequestBody, + options: UpdateBlockApiOptions, + root: string + ) => { + const res = await send(block.id, 'update-block', { + api: () => updateBlock(block), + cb: async (block: Block) => { + await getBlocks(root) + if (options.afterSuccess != null) { + options.afterSuccess(block) + } + }, + }) + return res + }, + [getBlocks, send] + ) + + return { + send, + sendingMap, + createBlock: createBlockApi, + deleteBlock: deleteBlockApi, + updateBlock: updateBlockApi, + } +} diff --git a/src/cloud/lib/hooks/useCloudResourceModals.tsx b/src/cloud/lib/hooks/useCloudResourceModals.tsx index e31e6e6531..ce11c7d984 100644 --- a/src/cloud/lib/hooks/useCloudResourceModals.tsx +++ b/src/cloud/lib/hooks/useCloudResourceModals.tsx @@ -1,4 +1,8 @@ -import { mdiFileDocumentOutline, mdiFolderOutline } from '@mdi/js' +import { + mdiFileDocumentOutline, + mdiFolderOutline, + mdiPaletteOutline, +} from '@mdi/js' import React, { useCallback } from 'react' import { FormRowProps } from '../../../design/components/molecules/Form/templates/FormRow' import EmojiInputForm from '../../../design/components/organisms/EmojiInputForm' @@ -161,7 +165,7 @@ export function useCloudResourceModals() { ) => { openModal( , { showCloseIcon: true, - title: translate(lngKeys.ModalsCreateNewDocument), + title: body.blocks + ? translate(lngKeys.CreateNewCanvas) + : translate(lngKeys.ModalsCreateNewDocument), } ) }, @@ -302,6 +309,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 new file mode 100644 index 0000000000..26ddd5274d --- /dev/null +++ b/src/cloud/lib/hooks/useDocBlocks.ts @@ -0,0 +1,69 @@ +import { useBlocks } from '../stores/blocks' +import { useEffect, useState, useMemo } from 'react' +import { + Block, + BlockCreateRequestBody, + BlockUpdateRequestBody, +} from '../../api/blocks' +import { + CreateBlockApiOptions, + DeleteBlockApiOptions, + UpdateBlockApiOptions, + useBlocksApi, +} from './useBlocksApi' +import { BulkApiActionRes } from '../../../design/lib/hooks/useBulkApi' + +type BlockState = { type: 'loading' } | { type: 'loaded'; block: Block } + +export type BlockActionCreate = ( + block: BlockCreateRequestBody, + parent: Block, + options?: CreateBlockApiOptions +) => Promise + +export type BlockActionUpdate = ( + block: BlockUpdateRequestBody, + options?: UpdateBlockApiOptions +) => Promise + +export type BlockActionRemove = ( + block: Block, + options?: DeleteBlockApiOptions +) => Promise + +export interface BlockActions { + create: BlockActionCreate + update: BlockActionUpdate + remove: BlockActionRemove +} + +export function useDocBlocks(id: string) { + const { observeDocBlocks } = useBlocks() + const { createBlock, updateBlock, deleteBlock, sendingMap } = useBlocksApi() + const [state, setState] = useState({ type: 'loading' }) + + useEffect(() => { + const unsub = observeDocBlocks(id, (block) => { + setState({ type: 'loaded', block }) + }) + return () => { + setState({ type: 'loading' }) + unsub() + } + }, [id, observeDocBlocks]) + + const actions: BlockActions = useMemo(() => { + return { + create: (block, parent, options) => + createBlock(block, parent, options || {}, id), + update: (block, options) => updateBlock(block, options || {}, id), + remove: (block, options) => deleteBlock(block, options || {}, id), + } + }, [id, createBlock, updateBlock, deleteBlock]) + + return { + state, + actions, + sendingMap, + } +} 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/fr.ts b/src/cloud/lib/i18n/fr.ts index 777941d8df..ff40f5a581 100644 --- a/src/cloud/lib/i18n/fr.ts +++ b/src/cloud/lib/i18n/fr.ts @@ -488,6 +488,7 @@ const frTranslation: TranslationSource = { [lngKeys.OnboardingFolderSectionDisclaimer]: 'Invitez vos coéquipiers dans cet espace', [lngKeys.GeneralContent]: 'Contenu', + [lngKeys.CreateNewCanvas]: 'Créer un nouveau Canvas (beta)', } export default { diff --git a/src/cloud/lib/i18n/ja.ts b/src/cloud/lib/i18n/ja.ts index c1b57e31c8..8d26788080 100644 --- a/src/cloud/lib/i18n/ja.ts +++ b/src/cloud/lib/i18n/ja.ts @@ -485,6 +485,7 @@ const jpTranslation: TranslationSource = { [lngKeys.OnboardingFolderSectionDisclaimer]: 'Invite your teammates to this space', [lngKeys.GeneralContent]: 'Content', + [lngKeys.CreateNewCanvas]: 'Create new canvas (beta)', } export default { 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', diff --git a/src/cloud/lib/i18n/zhCN.ts b/src/cloud/lib/i18n/zhCN.ts index 7f2b66aaa5..83d1f45cbd 100644 --- a/src/cloud/lib/i18n/zhCN.ts +++ b/src/cloud/lib/i18n/zhCN.ts @@ -461,6 +461,7 @@ const zhTranslation: TranslationSource = { [lngKeys.OnboardingFolderSectionDisclaimer]: 'Invite your teammates to this space', [lngKeys.GeneralContent]: 'Content', + [lngKeys.CreateNewCanvas]: 'Create new canvas (beta)', } export default { diff --git a/src/cloud/lib/stores/blocks/index.ts b/src/cloud/lib/stores/blocks/index.ts new file mode 100644 index 0000000000..c598e7b25c --- /dev/null +++ b/src/cloud/lib/stores/blocks/index.ts @@ -0,0 +1,78 @@ +import { createStoreContext } from '../../utils/context' +import { useRef, useCallback, useEffect } from 'react' +import { Block, getBlockTree } from '../../../api/blocks' +import { useToast } from '../../../../design/lib/stores/toast' +import { SerializedAppEvent } from '../../../interfaces/db/appEvents' + +type BlocksObserver = (blocks: Block) => void + +function useBlocksStore() { + const { pushApiErrorMessage } = useToast() + const treeCache = useRef>(new Map()) + const treeObservers = useRef>>(new Map()) + + const getBlocks = useCallback( + async (rootBlock: string, suppressError = false) => { + try { + 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) + } + return true + } catch (err) { + if (!suppressError) { + pushApiErrorMessage(err) + } + return false + } + }, + [pushApiErrorMessage] + ) + + const observeDocBlocks = useCallback( + (rootBlock: string, observer: BlocksObserver) => { + const observers = treeObservers.current.get(rootBlock) || new Set() + observers.add(observer) + treeObservers.current.set(rootBlock, observers) + Promise.resolve(() => { + if (treeCache.current.has(rootBlock)) { + observer(treeCache.current.get(rootBlock)!) + } + }) + getBlocks(rootBlock) + return () => { + observers.delete(observer) + } + }, + [getBlocks] + ) + + const getBlocksRef = useRef(getBlocks) + useEffect(() => { + getBlocksRef.current = getBlocks + }, [getBlocks]) + const blockEventListener = useCallback(async (event: SerializedAppEvent) => { + switch (event.type) { + case 'blockCreated': + case 'blockUpdated': + case 'blockDeleted': { + if (typeof event.data.rootBlockId === 'string') { + getBlocksRef.current(event.data.rootBlockId, true) + } + } + } + }, []) + + return { + observeDocBlocks, + getBlocks, + blockEventListener, + } +} + +export const { + StoreProvider: BlocksProvider, + useStore: useBlocks, +} = createStoreContext(useBlocksStore, 'blocks') diff --git a/src/cloud/lib/stores/sidebarCollapse/store.tsx b/src/cloud/lib/stores/sidebarCollapse/store.tsx index b7654c5226..29b3921b4c 100644 --- a/src/cloud/lib/stores/sidebarCollapse/store.tsx +++ b/src/cloud/lib/stores/sidebarCollapse/store.tsx @@ -14,6 +14,7 @@ const initialContent: CollapsableContent = { folders: [], workspaces: [], links: [], + blocks: [], } function useSidebarCollapseStore(): SidebarCollapseContext { @@ -82,6 +83,10 @@ function useSidebarCollapseStore(): SidebarCollapseContext { return new Set(currentTeamCollapsable.links) }, [currentTeamCollapsable]) + const sideBarOpenedBlocksIdsSet = useMemo(() => { + return new Set(currentTeamCollapsable.blocks) + }, [currentTeamCollapsable]) + // LOAD FROM LOCAL STORAGE useEffect(() => { if (team == null) { @@ -121,6 +126,7 @@ function useSidebarCollapseStore(): SidebarCollapseContext { sideBarOpenedFolderIdsSet, sideBarOpenedWorkspaceIdsSet, sideBarOpenedLinksIdsSet, + sideBarOpenedBlocksIdsSet, setToLocalStorage, toggleItem, unfoldItem, diff --git a/src/cloud/lib/stores/sidebarCollapse/types.ts b/src/cloud/lib/stores/sidebarCollapse/types.ts index 3b0a2ba953..898130c769 100644 --- a/src/cloud/lib/stores/sidebarCollapse/types.ts +++ b/src/cloud/lib/stores/sidebarCollapse/types.ts @@ -1,4 +1,4 @@ -export type CollapsableType = 'folders' | 'workspaces' | 'links' +export type CollapsableType = 'folders' | 'workspaces' | 'links' | 'blocks' export type CollapsableContent = { [type in CollapsableType]: string[] } @@ -10,6 +10,7 @@ export interface SidebarCollapseContext { sideBarOpenedFolderIdsSet: Set sideBarOpenedWorkspaceIdsSet: Set sideBarOpenedLinksIdsSet: Set + sideBarOpenedBlocksIdsSet: Set setToLocalStorage: (teamId: string, content: CollapsableContent) => void toggleItem: (type: CollapsableType, id: string) => void foldItem: (type: CollapsableType, id: string) => void diff --git a/src/cloud/lib/utils/blocks.ts b/src/cloud/lib/utils/blocks.ts new file mode 100644 index 0000000000..0c4164d421 --- /dev/null +++ b/src/cloud/lib/utils/blocks.ts @@ -0,0 +1,31 @@ +import { capitalize } from 'lodash' +import { compareDateString } from '../../../design/lib/date' +import { Block } from '../../api/blocks' + +export function orderBlockChildren({ children, ...rest }: Block): Block { + return { + ...rest, + children: + children != null + ? children + .sort((a, b) => { + return compareDateString(a.createdAt, b.createdAt, 'DESC') + }) + .map((block) => orderBlockChildren(block)) + : [], + } as Block +} + +export function blockTitle(block: Block) { + switch (block.type) { + case 'github.issue': + return block.data?.title || 'Github Issue' + case 'container': + return block.name.trim() === '' ? 'Page' : block.name + case 'embed': + case 'table': + return block.name.trim() === '' ? capitalize(block.type) : block.name + default: + return capitalize(block.type) + } +} diff --git a/src/cloud/lib/utils/events.ts b/src/cloud/lib/utils/events.ts index d7e7191fab..3cdc2ab7bd 100644 --- a/src/cloud/lib/utils/events.ts +++ b/src/cloud/lib/utils/events.ts @@ -80,3 +80,13 @@ export const toggleSidebarNotificationsEventEmitter = createCustomEventEmitter( export const toggleSettingsMembersEventEmitter = createCustomEventEmitter( 'toggle-settings-members' ) + +export type BlockEventDetails = { + blockId: string + blockType: 'markdown' | 'embed' | 'table' | 'container' | 'github.issue' + event: 'creation' +} + +export const blockEventEmitter = createCustomEventEmitter( + 'blocks-events' +) diff --git a/src/cloud/lib/utils/string.ts b/src/cloud/lib/utils/string.ts index ec83aedc2d..9a8ea841c7 100644 --- a/src/cloud/lib/utils/string.ts +++ b/src/cloud/lib/utils/string.ts @@ -69,3 +69,30 @@ export function capitalize(str: string) { export function stringifyUrl(url: Url): string { return typeof url === 'string' ? url : formatUrl(url) } + +export function isUrlOrPath(str: string): boolean { + return /^(?:http(s)?:\/\/)?[\w.-]+(?:\.[\w\.-]+)+[\w\-\._~:/\%?#[\]@!\$&'\(\)\*\+,;=.]+$/gim.test( + str + ) +} + +export function isNumberString(str: string) { + return !isNaN(Number(str)) +} + +export function parseBoolean(str: string, deflt = false) { + if (typeof str !== 'string') { + return deflt + } + + const lower = str.toLowerCase() + if (lower === 'true' || lower === '1') { + return true + } + + if (lower === 'false' || lower === '0') { + return false + } + + return deflt +} diff --git a/src/cloud/pages/cooperate.tsx b/src/cloud/pages/cooperate.tsx index 540770da53..dd4dc16523 100644 --- a/src/cloud/pages/cooperate.tsx +++ b/src/cloud/pages/cooperate.tsx @@ -86,6 +86,7 @@ const CooperatePage = () => { ...new Set(initialFolders.map((folder) => folder.workspaceId)), ], links: [], + blocks: [], }) if (intent != null) { 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 diff --git a/src/design/components/atoms/LeftRightList.tsx b/src/design/components/atoms/LeftRightList.tsx index b444d5b778..cfe79f8094 100644 --- a/src/design/components/atoms/LeftRightList.tsx +++ b/src/design/components/atoms/LeftRightList.tsx @@ -1,13 +1,16 @@ import React, { useRef } from 'react' import { useEffectOnce } from 'react-use' +import { focusFirstChildFromElement } from '../../../cloud/lib/dom' import { useLeftToRightNavigationListener } from '../../lib/keyboard' interface LeftToRightListProps { className?: string + ignoreFocus?: boolean } -const LeftToRightList: React.FC = ({ +const LeftRightList: React.FC = ({ className, children, + ignoreFocus, }) => { const listRef = useRef(null) useEffectOnce(() => { @@ -19,10 +22,24 @@ const LeftToRightList: React.FC = ({ useLeftToRightNavigationListener(listRef) return ( -
+
{ + if (ignoreFocus) { + return + } + + if (event.target === listRef.current) { + event.preventDefault() + focusFirstChildFromElement(event.target) + } + }} + tabIndex={0} + > {children}
) } -export default LeftToRightList +export default LeftRightList diff --git a/src/design/components/atoms/Link.tsx b/src/design/components/atoms/Link.tsx index 08dc7b79c2..ac38757ed3 100644 --- a/src/design/components/atoms/Link.tsx +++ b/src/design/components/atoms/Link.tsx @@ -5,6 +5,8 @@ import React, { } from 'react' import styled from '../../lib/styled' import cc from 'classcat' +import Icon from './Icon' +import { mdiOpenInNew } from '@mdi/js' export interface HyperLinkProps { id?: string @@ -20,18 +22,17 @@ export interface HyperLinkProps { onClick?: MouseEventHandler } -export const ExternalLink: React.FC = ({ - className, - children, - ...props -}) => ( +export const ExternalLink: React.FC< + HyperLinkProps & { showIcon?: boolean } +> = ({ className, children, showIcon, ...props }) => ( {children} + {showIcon && } ) @@ -66,4 +67,13 @@ const Container = styled.a` color: ${({ theme }) => theme.colors.text.link}; opacity: 0.8; } + + &.link--flex { + display: inline-flex; + align-items: center; + .icon { + flex: 0 0 auto; + margin-left: ${({ theme }) => theme.sizes.spaces.xsm}px; + } + } ` diff --git a/src/design/components/atoms/VerticalScroller.tsx b/src/design/components/atoms/Scroller.tsx similarity index 60% rename from src/design/components/atoms/VerticalScroller.tsx rename to src/design/components/atoms/Scroller.tsx index 0a8c1e425a..532eb67d51 100644 --- a/src/design/components/atoms/VerticalScroller.tsx +++ b/src/design/components/atoms/Scroller.tsx @@ -1,23 +1,22 @@ -import React from 'react' -import { AppComponent } from '../../lib/types' +import React, { PropsWithChildren } from 'react' import cc from 'classcat' import { OverlayScrollbarsComponent } from 'overlayscrollbars-react' -interface VerticalScrollerProps { +interface ScrollerProps { style?: React.CSSProperties onClick?: React.MouseEventHandler + className?: string + id?: string + ref?: React.LegacyRef overflowBehavior?: { x?: OverlayScrollbars.OverflowBehavior y?: OverlayScrollbars.OverflowBehavior } } -const VerticalScroller: AppComponent = ({ - style, - children, - className, - overflowBehavior, - onClick, -}) => { +const Scroller = React.forwardRef< + OverlayScrollbarsComponent, + PropsWithChildren +>(({ style, children, className, overflowBehavior, id, onClick }, ref) => { return ( = ({ }, overflowBehavior, }} + id={id} onClick={onClick} style={style} + ref={ref} > {children} ) -} +}) -export default VerticalScroller +export default React.memo(Scroller) diff --git a/src/design/components/molecules/Form/atoms/FormCheckbox.tsx b/src/design/components/molecules/Form/atoms/FormCheckbox.tsx index cca831059a..d8a7c430bb 100644 --- a/src/design/components/molecules/Form/atoms/FormCheckbox.tsx +++ b/src/design/components/molecules/Form/atoms/FormCheckbox.tsx @@ -84,10 +84,17 @@ export const CheckboxWithLabel: AppComponent = ({ toggle, ...props }) => ( - + { + ev.preventDefault() + if (toggle != null) { + toggle() + } + }} + > {typeof label === 'string' ? ( - + {label} ) : ( label )} diff --git a/src/design/components/molecules/Form/atoms/FormTextArea.tsx b/src/design/components/molecules/Form/atoms/FormTextArea.tsx index 4a23ae3df7..81f907556a 100644 --- a/src/design/components/molecules/Form/atoms/FormTextArea.tsx +++ b/src/design/components/molecules/Form/atoms/FormTextArea.tsx @@ -2,6 +2,7 @@ import React, { ChangeEventHandler, MouseEventHandler, FocusEventHandler, + KeyboardEventHandler, } from 'react' import cc from 'classcat' import styled from '../../../../lib/styled' @@ -30,6 +31,7 @@ export interface FormTextareaProps { onDoubleClick?: MouseEventHandler onContextMenu?: MouseEventHandler onFocus?: FocusEventHandler + onKeyDown?: KeyboardEventHandler } const FormTextarea = React.forwardRef( @@ -58,6 +60,7 @@ const FormTextarea = React.forwardRef( onDoubleClick, onContextMenu, onFocus, + onKeyDown, }, ref ) => { @@ -87,6 +90,7 @@ const FormTextarea = React.forwardRef( onDoubleClick={onDoubleClick} onContextMenu={onContextMenu} onFocus={onFocus} + onKeyDown={onKeyDown} /> ) } diff --git a/src/design/components/molecules/Navigation/NavigationItem.tsx b/src/design/components/molecules/Navigation/NavigationItem.tsx index bb69c29a0a..d965e5d19a 100644 --- a/src/design/components/molecules/Navigation/NavigationItem.tsx +++ b/src/design/components/molecules/Navigation/NavigationItem.tsx @@ -3,7 +3,7 @@ import styled from '../../../lib/styled' import { AppComponent, ControlButtonProps } from '../../../lib/types' import cc from 'classcat' import FoldingWrapper, { FoldingProps } from '../../atoms/FoldingWrapper' -import Button from '../../atoms/Button' +import Button, { LoadingButton } from '../../atoms/Button' import { mdiChevronDown, mdiChevronRight } from '@mdi/js' import { Emoji } from 'emoji-mart' import Icon from '../../atoms/Icon' @@ -132,10 +132,11 @@ const NavItem: AppComponent< )} {children} - {controls != null && ( + {controls != null && controls.length > 0 && (
{(controls || []).map((control, i) => ( - + {onSubmit != null && ( + + )} diff --git a/src/design/components/organisms/Dialog/molecules/MessageBoxDialogBody.tsx b/src/design/components/organisms/Dialog/molecules/MessageBoxDialogBody.tsx index 193ababd0a..1a392ef915 100644 --- a/src/design/components/organisms/Dialog/molecules/MessageBoxDialogBody.tsx +++ b/src/design/components/organisms/Dialog/molecules/MessageBoxDialogBody.tsx @@ -3,7 +3,7 @@ import { useEffectOnce } from 'react-use' import { MessageBoxButtonProps } from '../../../../lib/stores/dialog' import styled from '../../../../lib/styled' import Button from '../../../atoms/Button' -import LeftToRightList from '../../../atoms/LeftRightList' +import LeftRightList from '../../../atoms/LeftRightList' type MessageBoxDialogProps = { buttons?: MessageBoxButtonProps[] @@ -45,7 +45,7 @@ const MessageBoxDialogBody = ({ return ( - + {buttons == null ? ( )) )} - + ) } diff --git a/src/design/components/organisms/FuzzyNavigation/index.tsx b/src/design/components/organisms/FuzzyNavigation/index.tsx index 505176f521..09a9f2bf03 100644 --- a/src/design/components/organisms/FuzzyNavigation/index.tsx +++ b/src/design/components/organisms/FuzzyNavigation/index.tsx @@ -11,7 +11,7 @@ import FuzzyNavigationItem, { import Fuse from 'fuse.js' import CloseButtonWrapper from '../../molecules/CloseButtonWrapper' import cc from 'classcat' -import VerticalScroller from '../../atoms/VerticalScroller' +import Scroller from '../../atoms/Scroller' interface FuzzyNavigationProps { recentItems: FuzzyNavigationItemAttrbs[] @@ -88,7 +88,7 @@ const FuzzyNavigation = ({ }} /> - + {query === '' ? ( <> @@ -121,7 +121,7 @@ const FuzzyNavigation = ({ ))} )} - + ) diff --git a/src/design/components/organisms/InfoBlock/index.tsx b/src/design/components/organisms/InfoBlock/index.tsx new file mode 100644 index 0000000000..78c5a6e995 --- /dev/null +++ b/src/design/components/organisms/InfoBlock/index.tsx @@ -0,0 +1,143 @@ +import React, { useState } from 'react' +import { AppComponent } from '../../../lib/types' +import cc from 'classcat' +import styled from '../../../lib/styled' +import Flexbox from '../../atoms/Flexbox' +import Icon from '../../atoms/Icon' +import Button from '../../atoms/Button' +import { mdiArrowLeft, mdiArrowRight } from '@mdi/js' + +interface InfoBlockProps { + title?: string + titleIcon?: string | React.ReactNode + rows?: InfoBlockRow[] +} + +const InfoBlock: AppComponent = ({ + title, + titleIcon, + rows = [], + className, + children, +}) => { + return ( + + {(title != null || titleIcon != null) && ( + + {typeof titleIcon === 'string' ? ( + + ) : ( + titleIcon + )} + {title != null &&

{title}

} +
+ )} + {rows.map((row, i) => ( + + ))} + {children} +
+ ) +} + +const Container = styled.div` + .info__block__title { + color: ${({ theme }) => theme.colors.text.primary}; + .icon { + margin-right: ${({ theme }) => theme.sizes.spaces.sm}px; + } + } + + .info__block__row + .info__block__row { + margin-top: ${({ theme }) => theme.sizes.spaces.sm}px; + } +` + +export default InfoBlock + +export interface InfoBlockRow { + label?: string + labelIcon?: string | React.ReactNode + content?: string | React.ReactNode + minimized?: boolean +} + +export const InfoBlockRow: AppComponent = ({ + label, + labelIcon, + content, + minimized, + className, + children, +}) => { + const [showContent, setShowContent] = useState(minimized) + return ( + + + {labelIcon != null ? ( + typeof labelIcon === 'string' ? ( + + ) : ( + labelIcon + ) + ) : null} + {label} + +
+ {showContent != null && ( +
+
+ ) +} + +const RowContainer = styled.div` + display: flex; + width: 100%; + height: fit-content; + align-items: center; + + > * { + min-height: 20px; + } + + .info__block__row__label { + width: calc(100% / 4); + min-width: 100px; + max-width: 200px; + flex-direction: row; + flex-wrap: wrap; + color: ${({ theme }) => theme.colors.text.subtle}; + } + + .info__block__row__content__toggle { + flex: 0 0 auto; + margin-right: ${({ theme }) => theme.sizes.spaces.sm}px; + } + + .info__block__row__content, + .info__block__row__content__wrapper { + flex: 1 1 auto; + display: flex; + align-items: center; + } +` diff --git a/src/design/components/organisms/Modal/index.tsx b/src/design/components/organisms/Modal/index.tsx index a79938ff76..a1e3868a0b 100644 --- a/src/design/components/organisms/Modal/index.tsx +++ b/src/design/components/organisms/Modal/index.tsx @@ -6,8 +6,10 @@ import { isActiveElementAnInput } from '../../../lib/dom' import { useGlobalKeyDownHandler } from '../../../lib/keyboard' import styled from '../../../lib/styled' import Button from '../../atoms/Button' -import VerticalScroller from '../../atoms/VerticalScroller' +import Scroller from '../../atoms/Scroller' import { useWindow } from '../../../lib/stores/window' +import { OverlayScrollbarsComponent } from 'overlayscrollbars-react' +import { useEffectOnce } from 'react-use' const Modal = () => { const { modals, closeLastModal } = useModal() @@ -64,6 +66,7 @@ const ContextModalItem = ({ windowSize: { width: windowWidth, height: windowHeight }, } = useWindow() const modalWidth = typeof modal.width === 'string' ? 400 : modal.width + const contentScrollerRef = useRef(null) const style: CSSProperties | undefined = useMemo(() => { const properties: CSSProperties = { @@ -124,12 +127,22 @@ const ContextModalItem = ({ modal.maxHeight, ]) + useEffectOnce(() => { + if (contentScrollerRef.current != null) { + const instance = contentScrollerRef.current.osInstance() + if (instance != null) { + instance.scroll({ top: 0 }) + } + } + }) + return ( <>
- {modal.content}
- +
) @@ -175,7 +188,7 @@ const ModalItem = ({ ) return ( - @@ -204,7 +217,7 @@ const ModalItem = ({
{modal.content}
-
+ ) } diff --git a/src/design/components/organisms/SearchLayout/index.tsx b/src/design/components/organisms/SearchLayout/index.tsx index 807497cf92..b8b0a02c7d 100644 --- a/src/design/components/organisms/SearchLayout/index.tsx +++ b/src/design/components/organisms/SearchLayout/index.tsx @@ -12,7 +12,7 @@ import { overflowEllipsis } from '../../../lib/styled/styleFunctions' import FormInput from '../../molecules/Form/atoms/FormInput' import { mdiMagnify } from '@mdi/js' import Icon from '../../atoms/Icon' -import VerticalScroller from '../../atoms/VerticalScroller' +import Scroller from '../../atoms/Scroller' interface SearchLayoutProps { searchPlaceholder: string @@ -115,7 +115,7 @@ const SearchLayout = ({ /> - +
{searchQuery.trim() === '' && ( <> @@ -340,7 +340,7 @@ const SearchLayout = ({ )}
-
+
) 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] ) diff --git a/src/design/components/organisms/Sidebar/index.tsx b/src/design/components/organisms/Sidebar/index.tsx index fce70ac7b9..763af72a61 100644 --- a/src/design/components/organisms/Sidebar/index.tsx +++ b/src/design/components/organisms/Sidebar/index.tsx @@ -17,7 +17,7 @@ import SidebarTree, { SidebarNavCategory } from './molecules/SidebarTree' import SidebarPopOver from './atoms/SidebarPopOver' import SidebarSpaces, { SidebarSpaceProps } from './molecules/SidebarSpaces' import SidebarContextList from './atoms/SidebarContextList' -import VerticalScroller from '../../atoms/VerticalScroller' +import Scroller from '../../atoms/Scroller' export type PopOverState = null | 'spaces' | 'notifications' @@ -79,14 +79,14 @@ const Sidebar = ({ >
{header}
- + {tree == null ? ( ) : ( )} {treeBottomRows} - +
diff --git a/src/design/components/organisms/Table/index.tsx b/src/design/components/organisms/Table/index.tsx new file mode 100644 index 0000000000..3f7340ab26 --- /dev/null +++ b/src/design/components/organisms/Table/index.tsx @@ -0,0 +1,189 @@ +import React from 'react' +import styled from '../../../lib/styled' +import cc from 'classcat' + +import { AppComponent } from '../../../lib/types' +import { generateId } from '../../../../lib/string' +import Scroller from '../../atoms/Scroller' + +interface TableProps { + bordered?: boolean + type?: 'transparent' | 'striped' + rows?: TableRowProps[] + scroll: 'all' | 'after-first-col' +} + +const Table: AppComponent = ({ + className, + children, + bordered = true, + type = 'transparent', + rows = [], +}) => { + const tableId = generateId() + return ( + + + {rows.map((row, i) => ( + + ))} + {children} + + + ) +} + +interface TableRowProps { + isHeaderRow?: boolean +} + +export const TableRow = () =>
+ +interface TableCellProps { + isHeaderCell?: boolean + onClick?: () => void +} + +export const TableCell = (_props: TableCellProps) => ( +
+) + +export default Table + +const TableContainer = styled.div` + display: block; + width: 100%; + position: relative; + + * { + box-sizing: border-box; + } + + .table__row { + display: flex; + flex-flow: row wrap; + border-left: solid 1px ${({ theme }) => theme.colors.border.main}; + transition: 0.5s; + + &.table__row--header { + border-top: solid 1px ${({ theme }) => theme.colors.border.main}; + border-left: solid 1px ${({ theme }) => theme.colors.border.main}; + + .table__cell { + background: $table-header; + color: white; + border-color: $table-header-border; + } + } + + &.table__row--body:nth-child(odd) .table__cell { + background: $row-bg; + } + &:hover { + background: #f5f5f5; + transition: 500ms; + } + } + + .table__cell { + width: calc(100% / 4); + text-align: center; + padding: 0.5em 0.5em; + border-right: solid 1px ${({ theme }) => theme.colors.border.main}; + border-bottom: solid 1px ${({ theme }) => theme.colors.border.main}; + } + + .rowspan { + display: flex; + flex-flow: row wrap; + align-items: flex-start; + justify-content: center; + } + + .column { + display: flex; + flex-flow: column wrap; + width: 75%; + padding: 0; + .table__cell { + display: flex; + flex-flow: row wrap; + width: 100%; + padding: 0; + border: 0; + border-bottom: solid 1px ${({ theme }) => theme.colors.border.main}; + &:hover { + background: #f5f5f5; + transition: 500ms; + } + } + } + + .table__cell__verticals { + width: calc(100% / 3); + text-align: center; + padding: 0.5em 0.5em; + border-right: solid 1px ${({ theme }) => theme.colors.border.main}; + &:last-child { + } + } + + @media all and (max-width: 767px) { + .table__cell { + width: calc(100% / 3); + + &.first { + width: 100%; + } + } + + .column { + width: 100%; + } + } + + @media all and (max-width: 430px) { + .table__row { + .table__cell { + border-bottom: 0; + } + .table__cell:last-of-type { + border-bottom: solid 1px ${({ theme }) => theme.colors.border.main}; + } + } + + .header { + .table__cell { + border-bottom: solid 1px; + } + } + + .table__cell { + width: 100%; + + &.first { + width: 100%; + border-bottom: solid 1px ${({ theme }) => theme.colors.border.main}; + } + } + + .column { + width: 100%; + .table__cell { + border-bottom: solid 1px ${({ theme }) => theme.colors.border.main}; + } + } + + .table__cell__verticals { + width: 100%; + } + } +` diff --git a/src/design/components/organisms/Toolbar/index.tsx b/src/design/components/organisms/Toolbar/index.tsx new file mode 100644 index 0000000000..b7f45900ed --- /dev/null +++ b/src/design/components/organisms/Toolbar/index.tsx @@ -0,0 +1,82 @@ +import React from 'react' +import styled from '../../../lib/styled' +import { AppComponent } from '../../../lib/types' +import cc from 'classcat' +import { LoadingButton } from '../../atoms/Button' + +export interface ToolbarControlProps { + iconPath?: string + onClick?: (event: React.MouseEvent) => void + disabled?: boolean + spinning?: boolean + label?: React.ReactNode + className?: string +} + +interface ToolbarProps { + position?: 'absolute' | 'sticky' | 'fixed' + controls?: ToolbarControlProps[] +} + +const Toolbar: AppComponent = ({ + children, + className, + controls = [], +}) => { + return ( + +
+ {(controls || []).map((control, i) => ( + + {control.label} + + ))} + {children} +
+
+ ) +} + +const ToolbarRow = styled.div` + width: fit-content; + min-height: 40px; + background: ${({ theme }) => theme.colors.background.secondary}; + position: absolute; + left: 50%; + transform: translateX(-50%); + z-index: 1; + bottom: 6px; + border-radius: ${({ theme }) => theme.borders.radius}px; + max-width: 96%; + + .toolbar__wrapper { + padding: ${({ theme }) => theme.sizes.spaces.sm}px + ${({ theme }) => theme.sizes.spaces.df}px 0 + ${({ theme }) => theme.sizes.spaces.df}px; + display: flex; + align-items: center; + height: 100%; + flex: 0 1 auto; + flex-wrap: wrap; + justify-content: center !important; + } + + .toolbar__wrapper > * { + margin-bottom: ${({ theme }) => theme.sizes.spaces.sm}px; + } + + .toolbar__tool { + line-height: ; + } +` + +export default Toolbar diff --git a/src/design/components/templates/ContentLayout.tsx b/src/design/components/templates/ContentLayout.tsx index a756352b62..126b523770 100644 --- a/src/design/components/templates/ContentLayout.tsx +++ b/src/design/components/templates/ContentLayout.tsx @@ -132,6 +132,9 @@ const Container = styled.div` .two__pane__left { display: flex; flex-direction: column; + flex: 1 1 auto; + width: 100%; + height: 100%; } .topbar { diff --git a/src/design/lib/hooks/useBulkApi.ts b/src/design/lib/hooks/useBulkApi.ts index 6eec8242b6..8002c326af 100644 --- a/src/design/lib/hooks/useBulkApi.ts +++ b/src/design/lib/hooks/useBulkApi.ts @@ -1,11 +1,15 @@ import { useCallback, useState } from 'react' import { useToast } from '../stores/toast' +export type BulkApiActionRes = + | { err: true; error: unknown } + | { err: false; data: any } + export type BulkApiAction = ( id: string, act: string, body: { api: (args: any) => Promise; cb?: (res: any) => any } -) => Promise<{ err: boolean; error?: unknown }> +) => Promise interface UseBulkApiRes { sendingMap: Map @@ -18,7 +22,7 @@ const useBulkApi = () => { const send = useCallback( async (id: string, act: string, { api, cb }) => { - const res = { err: false, error: undefined } + const res = { err: false, error: undefined, data: undefined } if (sendingMap.get(id)) { return } @@ -31,6 +35,7 @@ const useBulkApi = () => { }) try { const data = await api() + res.data = data if (cb != null) { cb(data) } diff --git a/src/design/lib/keyboard.ts b/src/design/lib/keyboard.ts index 2b468c1ad5..74909b369f 100644 --- a/src/design/lib/keyboard.ts +++ b/src/design/lib/keyboard.ts @@ -60,13 +60,13 @@ export const useLeftToRightNavigationListener = ( } if (isSingleKeyEvent(event, 'arrowleft')) { - navigateToNextFocusableWithin(listRef.current, true) + navigateToPreviousFocusableWithin(listRef.current, true) preventKeyboardEventPropagation(event) return } if (isSingleKeyEvent(event, 'arrowright')) { - navigateToPreviousFocusableWithin(listRef.current, true) + navigateToNextFocusableWithin(listRef.current, true) preventKeyboardEventPropagation(event) return } diff --git a/src/design/lib/stores/modal/store.ts b/src/design/lib/stores/modal/store.ts index d087ebb226..1d070782f9 100644 --- a/src/design/lib/stores/modal/store.ts +++ b/src/design/lib/stores/modal/store.ts @@ -30,7 +30,16 @@ function useModalStore(): ModalsContext { alignment: options.alignment || 'bottom-left', }, } - setModals([modal]) + + if (!options.keepAll) { + setModals([modal]) + } else { + setModals((prev) => { + const newArray = prev.slice() + newArray.push(modal) + return newArray + }) + } }, [] ) diff --git a/src/design/lib/types.ts b/src/design/lib/types.ts index dc0f2f3d18..f95863c27d 100644 --- a/src/design/lib/types.ts +++ b/src/design/lib/types.ts @@ -3,6 +3,7 @@ export type AppComponent

= React.FC

export type ControlButtonProps = { disabled?: boolean active?: boolean + spinning?: boolean icon: string onClick: (event: React.MouseEvent) => void onContextMenu?: (event: React.MouseEvent) => void 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 +}