diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4929729..72f80f2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -30,6 +30,10 @@ jobs: id: install-playwright run: npx playwright install --with-deps + - name: lint + id: npm-lint + run: npm run lint + - name: run tests id: npm-test run: CI=1 npm test diff --git a/eslint.config.js b/eslint.config.js index d5a5a37..6e14fdd 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,31 +1,166 @@ -import js from '@eslint/js' -import globals from 'globals' -import reactHooks from 'eslint-plugin-react-hooks' -import reactRefresh from 'eslint-plugin-react-refresh' -import tseslint from 'typescript-eslint' - -export default tseslint.config( - { ignores: ['dist'] }, +import eslint from '@eslint/js'; +import globals from 'globals'; +import tseslint from 'typescript-eslint'; +import vitest from '@vitest/eslint-plugin'; +import reactHooks from 'eslint-plugin-react-hooks'; +import reactRefresh from 'eslint-plugin-react-refresh'; +import stylistic from '@stylistic/eslint-plugin'; + +import { defineConfig, globalIgnores } from 'eslint/config'; + +export default defineConfig([ { + files: ['**/*.{js,mjs,cjs}'], extends: [ - js.configs.recommended, - ...tseslint.configs.recommended + eslint.configs.recommended, ], - files: ['**/*.{ts,tsx}'], languageOptions: { - ecmaVersion: 2020, - globals: globals.browser, + ecmaVersion: 'latest', + sourceType: 'module', + globals: { ...globals.browser, ...globals.node }, }, + }, + { + files: ['**/*.{ts,tsx}'], + extends: [ + eslint.configs.recommended, + ...tseslint.configs.recommendedTypeChecked, + ...tseslint.configs.stylisticTypeChecked, + ], plugins: { 'react-hooks': reactHooks, 'react-refresh': reactRefresh, }, + languageOptions: { + parserOptions: { + projectService: { + allowDefaultProject: [ + 'vite.config.ts', + 'vitest.config.ts', + 'vitest.browser.config.ts', + 'global.d.ts', + ], + defaultProject: './tsconfig.test.json', + }, + tsconfigRootDir: import.meta.dirname, + }, + globals: { ...globals.browser, ...globals.node }, + }, rules: { ...reactHooks.configs.recommended.rules, - 'react-refresh/only-export-components': [ + 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }], + + 'no-console': ['error', { allow: ['warn', 'error'] }], + + 'no-unused-vars': 'off', + '@typescript-eslint/no-unused-vars': ['error', { varsIgnorePattern: '^_', argsIgnorePattern: '^_' }], + + '@typescript-eslint/no-unused-expressions': ['error', { allowShortCircuit: true }], + + 'no-continue': 'off', + 'no-param-reassign': 'off', + 'prefer-destructuring': 'off', + 'arrow-body-style': 'off', + + 'no-void': ['error', { allowAsStatement: true }], + + 'no-use-before-define': ['error', { functions: false, classes: false }], + '@typescript-eslint/no-use-before-define': ['error', { functions: false, classes: false }], + + '@typescript-eslint/no-namespace': 'off', + + '@typescript-eslint/space-infix-ops': 'off', + + '@typescript-eslint/no-empty-object-type': ['warn', { allowInterfaces: 'always' }], + + '@typescript-eslint/no-base-to-string': [ + 'error', + { ignoredTypeNames: ['URI', 'Error', 'RegExp', 'URL', 'URLSearchParams'] }, + ], + + 'no-underscore-dangle': [ 'warn', - { allowConstantExport: true }, + { + allow: ['_links', '_embedded', '_meta', '_type', '_destroy', '_tiptapEditor', '__dirname'], + allowAfterThis: true, + allowAfterSuper: false, + allowAfterThisConstructor: false, + enforceInMethodNames: true, + allowFunctionParams: true, + }, + ], + + 'no-return-assign': ['error', 'except-parens'], + 'no-plusplus': ['error', { allowForLoopAfterthoughts: true }], + + 'class-methods-use-this': 'off', + }, + }, + { + files: ['**/*.spec.ts', '**/*.test.ts', '**/*.test.tsx'], + ...vitest.configs.recommended, + rules: { + ...vitest.configs.recommended.rules, + + '@typescript-eslint/no-unsafe-member-access': 'off', + '@typescript-eslint/no-unsafe-call': 'off', + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-unsafe-assignment': 'off', + '@typescript-eslint/no-unsafe-argument': 'off', + '@typescript-eslint/require-await': 'off', + + 'max-classes-per-file': 'off', + 'vitest/no-commented-out-tests': 'off', + }, + }, + { + files: ['test/**/*.ts', 'test/**/*.tsx'], + rules: { + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-unsafe-assignment': 'off', + '@typescript-eslint/no-unsafe-argument': 'off', + '@typescript-eslint/no-unsafe-member-access': 'off', + '@typescript-eslint/no-unsafe-call': 'off', + '@typescript-eslint/require-await': 'off', + '@typescript-eslint/no-floating-promises': 'off', + '@typescript-eslint/await-thenable': 'off', + '@typescript-eslint/no-empty-function': 'off', + '@typescript-eslint/no-unused-vars': 'off', + 'react-refresh/only-export-components': 'off', + }, + }, + { + files: ['lib/**/*.{ts,tsx}'], + rules: { + 'react-refresh/only-export-components': 'off', + }, + }, + { + plugins: { '@stylistic': stylistic }, + rules: { + '@stylistic/semi': ['error', 'always'], + '@stylistic/max-len': 'off', + '@stylistic/object-curly-newline': 'off', + '@stylistic/quotes': ['error', 'single', { avoidEscape: true }], + '@stylistic/implicit-arrow-linebreak': 'off', + '@stylistic/lines-between-class-members': ['error', 'always', { exceptAfterSingleLine: true }], + '@stylistic/indent': 'off', + '@stylistic/type-annotation-spacing': [ + 'error', + { + before: false, + after: false, + overrides: { + arrow: { before: true, after: true }, + }, + }, ], + '@stylistic/spaced-comment': 'off', }, }, -) + globalIgnores([ + 'dist/', + 'coverage/', + '**/vendor', + ]), +]); diff --git a/global.d.ts b/global.d.ts index 444f793..f64df2e 100644 --- a/global.d.ts +++ b/global.d.ts @@ -1,4 +1,4 @@ declare module '*.css' { - const content: { [className: string]: string }; + const content:Record; export default content; } diff --git a/lib/components/BlockWorkPackage/BlockCard.tsx b/lib/components/BlockWorkPackage/BlockCard.tsx index b86ba0d..a952cf9 100644 --- a/lib/components/BlockWorkPackage/BlockCard.tsx +++ b/lib/components/BlockWorkPackage/BlockCard.tsx @@ -1,24 +1,24 @@ -import { forwardRef } from "react"; -import type { WorkPackage } from "../../openProjectTypes"; -import type { BlockWpSize } from "../WorkPackage/types"; -import { BlockCardM, BlockCardL, BlockCardXL } from "./BlockCards"; +import { forwardRef } from 'react'; +import type { WorkPackage } from '../../openProjectTypes'; +import type { BlockWpSize } from '../WorkPackage/types'; +import { BlockCardM, BlockCardL, BlockCardXL } from './BlockCards'; export interface BlockCardProps { - workPackage: WorkPackage; - size?: BlockWpSize; - inDropdown?: boolean; - linkTitle?: boolean; - onClick?: (e: React.MouseEvent) => void; + workPackage:WorkPackage; + size?:BlockWpSize; + inDropdown?:boolean; + linkTitle?:boolean; + onClick?:(e:React.MouseEvent) => void; } export const BlockCard = forwardRef( - ({ workPackage, size = "m", inDropdown, linkTitle, onClick }, ref) => { + ({ workPackage, size = 'm', inDropdown, linkTitle, onClick }, ref) => { const shared = { workPackage, inDropdown, linkTitle, onClick, cardRef: ref }; - if (size === "xl") return ; - if (size === "l") return ; + if (size === 'xl') return ; + if (size === 'l') return ; return ; } ); -BlockCard.displayName = "BlockCard"; \ No newline at end of file +BlockCard.displayName = 'BlockCard'; \ No newline at end of file diff --git a/lib/components/BlockWorkPackage/BlockCards.tsx b/lib/components/BlockWorkPackage/BlockCards.tsx index 95f2707..7875bde 100644 --- a/lib/components/BlockWorkPackage/BlockCards.tsx +++ b/lib/components/BlockWorkPackage/BlockCards.tsx @@ -1,6 +1,6 @@ -import styled from "styled-components"; -import type { WorkPackage } from "../../openProjectTypes"; -import { linkToWorkPackage } from "../../services/openProjectApi"; +import styled from 'styled-components'; +import type { WorkPackage } from '../../openProjectTypes'; +import { linkToWorkPackage } from '../../services/openProjectApi'; import { defaultWpVariables, WorkPackageId, @@ -8,26 +8,26 @@ import { WorkPackageStatus, WorkPackageTitle, WorkPackageTitleLink, -} from "../WorkPackage/atoms"; +} from '../WorkPackage/atoms'; import { typeColor, statusColor, statusBorderColor, statusTextColor, statusBackgroundColor, -} from "../../services/colors"; -import { formatWorkPackageId } from "../../utils/id"; +} from '../../services/colors'; +import { formatWorkPackageId } from '../../utils/id'; const DESCRIPTION_MAX_CHARS = 300; export interface BlockCardSharedProps { - workPackage: WorkPackage; - inDropdown?: boolean; - linkTitle?: boolean; - onClick?: (e: React.MouseEvent) => void; + workPackage:WorkPackage; + inDropdown?:boolean; + linkTitle?:boolean; + onClick?:(e:React.MouseEvent) => void; } -function buildTitle(workPackage: WorkPackage, linkTitle: boolean) { +function buildTitle(workPackage:WorkPackage, linkTitle:boolean) { const href = linkToWorkPackage(workPackage.displayId); if (!linkTitle) return workPackage.subject; return ( @@ -36,7 +36,7 @@ function buildTitle(workPackage: WorkPackage, linkTitle: boolean) { onClick={(e) => { e.preventDefault(); e.stopPropagation(); - window.open(href, "_blank", "noopener,noreferrer"); + window.open(href, '_blank', 'noopener,noreferrer'); }} > {workPackage.subject} @@ -44,7 +44,7 @@ function buildTitle(workPackage: WorkPackage, linkTitle: boolean) { ); } -const CardBase = styled.div<{ $inDropdown: boolean }>` +const CardBase = styled.div<{ $inDropdown:boolean }>` ${defaultWpVariables} padding: var(--spacer-m) var(--spacer-l); background-color: var(--highlight-wp-background); @@ -59,7 +59,7 @@ const CardBase = styled.div<{ $inDropdown: boolean }>` `; const CardDetails = styled.div.attrs({ - className: "op-bn-work-package--details", + className: 'op-bn-work-package--details', })` display: flex; flex-wrap: wrap; @@ -97,14 +97,14 @@ export const BlockCardM = ({ linkTitle = false, onClick, cardRef, -}: BlockCardSharedProps & { cardRef?: React.Ref }) => ( +}:BlockCardSharedProps & { cardRef?:React.Ref }) => ( @@ -132,14 +132,14 @@ export const BlockCardL = ({ linkTitle = false, onClick, cardRef, -}: BlockCardSharedProps & { cardRef?: React.Ref }) => ( +}:BlockCardSharedProps & { cardRef?:React.Ref }) => ( @@ -172,7 +172,7 @@ export const BlockCardXL = ({ linkTitle = false, onClick, cardRef, -}: BlockCardSharedProps & { cardRef?: React.Ref }) => { +}:BlockCardSharedProps & { cardRef?:React.Ref }) => { const rawDescription = workPackage.description?.raw; const snippetText = rawDescription ? rawDescription.slice(0, DESCRIPTION_MAX_CHARS) @@ -188,7 +188,7 @@ export const BlockCardXL = ({ $inDropdown={inDropdown} onClick={onClick} data-testid="block-card" - style={onClick ? { cursor: "pointer" } : undefined} + style={onClick ? { cursor: 'pointer' } : undefined} > @@ -214,7 +214,7 @@ export const BlockCardXL = ({ {snippetText && ( {snippetText} - {isTruncated && "…"} + {isTruncated && '…'} )} diff --git a/lib/components/BlockWorkPackage/BlockWorkPackage.tsx b/lib/components/BlockWorkPackage/BlockWorkPackage.tsx index c71d2ba..1ca9c91 100644 --- a/lib/components/BlockWorkPackage/BlockWorkPackage.tsx +++ b/lib/components/BlockWorkPackage/BlockWorkPackage.tsx @@ -1,22 +1,22 @@ -import { BlockNoteEditor } from "@blocknote/core"; -import { useCallback, useEffect, useRef, useState } from "react"; -import styled from "styled-components"; -import { useTranslation } from "react-i18next"; -import { useWorkPackage } from "../../hooks/useWorkPackage"; -import { useColors } from "../../services/colors"; -import { wpBridge } from "../../services/wpBridge"; -import type { WorkPackage } from "../../openProjectTypes"; -import type { InlineWpSize, BlockWpSize } from "../WorkPackage/types"; -import { BlockCard } from "./BlockCard"; -import { UnavailableCard } from "../WorkPackage/UnavailableCard"; -import { WpOptionsPopover } from "../WorkPackage/OptionsPopover"; -import { SearchContainer, SearchLabel } from "../Search/SearchContainer"; -import { SearchDropdown } from "../Search/SearchDropdown"; -import { defaultWpVariables } from "../WorkPackage/atoms"; -import { formatWorkPackageId } from "../../utils/id"; -import { moveCursorAfterBlock } from "../../utils/cursor"; - -const Block = styled.div.attrs({ className: "op-bn-extensions" })` +import { BlockNoteEditor } from '@blocknote/core'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import styled from 'styled-components'; +import { useTranslation } from 'react-i18next'; +import { useWorkPackage } from '../../hooks/useWorkPackage'; +import { useColors } from '../../services/colors'; +import { wpBridge } from '../../services/wpBridge'; +import type { WorkPackage } from '../../openProjectTypes'; +import type { InlineWpSize, BlockWpSize } from '../WorkPackage/types'; +import { BlockCard } from './BlockCard'; +import { UnavailableCard } from '../WorkPackage/UnavailableCard'; +import { WpOptionsPopover } from '../WorkPackage/OptionsPopover'; +import { SearchContainer, SearchLabel } from '../Search/SearchContainer'; +import { SearchDropdown } from '../Search/SearchDropdown'; +import { defaultWpVariables } from '../WorkPackage/atoms'; +import { formatWorkPackageId } from '../../utils/id'; +import { moveCursorAfterBlock } from '../../utils/cursor'; + +const Block = styled.div.attrs({ className: 'op-bn-extensions' })` ${defaultWpVariables} background-color: var(--op-chip-bg); border-radius: var(--bn-border-radius); @@ -28,20 +28,21 @@ const BlockCardWrapper = styled.div` `; interface BlockProps { - id: string; - props: { - wpid?: number; - initialized?: boolean; - size?: BlockWpSize; + id:string; + props:{ + wpid?:number; + initialized?:boolean; + size?:BlockWpSize; }; } export const BlockWorkPackageComponent = ({ block, editor, -}: { - block: BlockProps; - editor: BlockNoteEditor; +}:{ + block:BlockProps; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + editor:BlockNoteEditor; }) => { const { t } = useTranslation(); const cardRef = useRef(null); @@ -55,9 +56,9 @@ export const BlockWorkPackageComponent = ({ const workPackageResult = useWorkPackage(block.props.wpid); const selectedWorkPackage = workPackageResult.workPackage; - const cardSize: BlockWpSize = block.props.size ?? "m"; + const cardSize:BlockWpSize = block.props.size ?? 'm'; - const handleSelectWorkPackage = (wp: WorkPackage) => { + const handleSelectWorkPackage = (wp:WorkPackage) => { editor.updateBlock(block, { props: { ...block.props, wpid: wp.id, initialized: true }, }); @@ -66,6 +67,7 @@ export const BlockWorkPackageComponent = ({ useEffect(() => { // accessing private tiptap instance until public API is available + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access const tiptap = (editor as any)._tiptapEditor; if (!tiptap) return; @@ -73,25 +75,27 @@ export const BlockWorkPackageComponent = ({ setIsActive(editor.getTextCursorPosition()?.block?.id === block.id); }; - tiptap.on("selectionUpdate", updateActiveState); + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + tiptap.on('selectionUpdate', updateActiveState); updateActiveState(); - return () => { tiptap.off("selectionUpdate", updateActiveState); }; + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + return () => { tiptap.off('selectionUpdate', updateActiveState); }; }, [editor, block.id]); // Close options popover on outside click useEffect(() => { if (!isOptionsOpen) return; - const handleClickOutside = (e: MouseEvent) => { + const handleClickOutside = (e:MouseEvent) => { if (cardRef.current && !cardRef.current.contains(e.target as Node)) { setIsOptionsOpen(false); } }; - document.addEventListener("mousedown", handleClickOutside); - return () => document.removeEventListener("mousedown", handleClickOutside); + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); }, [isOptionsOpen]); const handleCopy = useCallback( - (e: ClipboardEvent) => { + (e:ClipboardEvent) => { if (!isOptionsOpen || !block.props.wpid) return; e.preventDefault(); @@ -100,9 +104,9 @@ export const BlockWorkPackageComponent = ({ const wpid = block.props.wpid; const formattedId = formatWorkPackageId(selectedWorkPackage?.displayId ?? String(wpid)); - e.clipboardData?.setData("text/plain", formattedId); + e.clipboardData?.setData('text/plain', formattedId); e.clipboardData?.setData( - "text/html", + 'text/html', `
${formattedId}
`, ); }, @@ -115,16 +119,16 @@ export const BlockWorkPackageComponent = ({ // Chrome doesn't expose clipboardData on copy events that bubble past a // shadow boundary - attach to the nearest root to get a writable event. const root = (cardRef.current?.getRootNode() ?? document) as Document | ShadowRoot; - root.addEventListener("copy", handleCopy as EventListener); - return () => root.removeEventListener("copy", handleCopy as EventListener); + root.addEventListener('copy', handleCopy as EventListener); + return () => root.removeEventListener('copy', handleCopy as EventListener); }, [isOptionsOpen, handleCopy]); - const handleConvertToInline = (size: InlineWpSize) => { + const handleConvertToInline = (size:InlineWpSize) => { if (!selectedWorkPackage) return; wpBridge.convertToInline({ wpid: selectedWorkPackage.id, size, blockId: block.id }); }; - const handleResizeBlock = (size: BlockWpSize) => { + const handleResizeBlock = (size:BlockWpSize) => { editor.updateBlock(block, { props: { ...block.props, size }, }); @@ -139,13 +143,13 @@ export const BlockWorkPackageComponent = ({ return ( -
+
{!block.props.wpid && !block.props.initialized && isActive && ( - {t("search.label")} + {t('search.label')} {workPackageResult.loading && ( )} {!workPackageResult.loading && workPackageResult.error && ( )} {!workPackageResult.loading && !workPackageResult.error && workPackageResult.unauthorized && ( )} {!workPackageResult.loading && @@ -202,6 +206,7 @@ export const BlockWorkPackageComponent = ({ currentSize={undefined} currentBlockSize={cardSize} instanceId={undefined} + // eslint-disable-next-line react-hooks/refs anchorEl={cardRef.current} onClose={() => setIsOptionsOpen(false)} onConvertToInline={handleConvertToInline} @@ -218,4 +223,3 @@ export const BlockWorkPackageComponent = ({ ); }; - diff --git a/lib/components/BlockWorkPackage/index.ts b/lib/components/BlockWorkPackage/index.ts index 58236a3..0b8c296 100644 --- a/lib/components/BlockWorkPackage/index.ts +++ b/lib/components/BlockWorkPackage/index.ts @@ -1 +1 @@ -export { openProjectWorkPackageBlockSpec } from "./spec"; +export { openProjectWorkPackageBlockSpec } from './spec'; diff --git a/lib/components/BlockWorkPackage/spec.tsx b/lib/components/BlockWorkPackage/spec.tsx index 27eb9ff..918ed48 100644 --- a/lib/components/BlockWorkPackage/spec.tsx +++ b/lib/components/BlockWorkPackage/spec.tsx @@ -1,15 +1,15 @@ -import { createBlockConfig } from "@blocknote/core"; -import { createReactBlockSpec } from "@blocknote/react"; -import { BlockWorkPackageComponent } from "./BlockWorkPackage"; +import { createBlockConfig } from '@blocknote/core'; +import { createReactBlockSpec } from '@blocknote/react'; +import { BlockWorkPackageComponent } from './BlockWorkPackage'; export const blockConfig = createBlockConfig((() => ({ - type: "openProjectWorkPackageBlock" as const, + type: 'openProjectWorkPackageBlock' as const, propSchema: { - wpid: { default: undefined, type: "number" }, - initialized: { default: false, type: "boolean" }, - size: { default: "m", type: "string" }, + wpid: { default: undefined, type: 'number' }, + initialized: { default: false, type: 'boolean' }, + size: { default: 'm', type: 'string' }, }, - content: "none", + content: 'none', isSelectable: false, })) as unknown as ReturnType); @@ -19,18 +19,23 @@ export const openProjectWorkPackageBlockSpec = createReactBlockSpec( render: (props) => ( ), toExternalHTML: ({ block }) => { - const { wpid, size, initialized } = block.props; + const { wpid, size, initialized } = block.props as { + wpid:number | undefined; + size:string | undefined; + initialized:boolean | undefined; + }; if (!wpid) return <>; return (
#{wpid} @@ -39,11 +44,11 @@ export const openProjectWorkPackageBlockSpec = createReactBlockSpec( }, parse: (element) => { - if (element.getAttribute("data-block-content-type") !== "openProjectWorkPackageBlock") { + if (element.getAttribute('data-block-content-type') !== 'openProjectWorkPackageBlock') { return undefined; } - const wpid = element.getAttribute("data-wpid"); - const size = element.getAttribute("data-size") ?? "m"; + const wpid = element.getAttribute('data-wpid'); + const size = element.getAttribute('data-size') ?? 'm'; return { wpid: wpid ? Number(wpid) : undefined, size, diff --git a/lib/components/HashMenu/HashWpMenu.tsx b/lib/components/HashMenu/HashWpMenu.tsx index 235fa0f..cb0ceba 100644 --- a/lib/components/HashMenu/HashWpMenu.tsx +++ b/lib/components/HashMenu/HashWpMenu.tsx @@ -1,13 +1,13 @@ -import type { FC, RefObject } from "react"; -import type { SuggestionMenuProps } from "@blocknote/react"; -import styled from "styled-components"; -import { BlockCard } from "../BlockWorkPackage/BlockCard"; -import { defaultWpVariables } from "../WorkPackage/atoms"; -import type { WorkPackage } from "../../openProjectTypes"; -import type { HashMenuItem } from "./types"; -import { useTranslation } from "react-i18next"; +import type { FC, RefObject } from 'react'; +import type { SuggestionMenuProps } from '@blocknote/react'; +import styled from 'styled-components'; +import { BlockCard } from '../BlockWorkPackage/BlockCard'; +import { defaultWpVariables } from '../WorkPackage/atoms'; +import type { WorkPackage } from '../../openProjectTypes'; +import type { HashMenuItem } from './types'; +import { useTranslation } from 'react-i18next'; -const Menu = styled.div.attrs({ className: "op-bn-hash-menu" })` +const Menu = styled.div.attrs({ className: 'op-bn-hash-menu' })` ${defaultWpVariables} background: var(--bn-colors-menu-background, #fff); box-shadow: var(--bn-shadow-medium); @@ -17,10 +17,10 @@ const Menu = styled.div.attrs({ className: "op-bn-hash-menu" })` max-width: 480px; `; -const MenuItem = styled.div<{ $selected: boolean }>` +const MenuItem = styled.div<{ $selected:boolean }>` border-radius: var(--bn-border-radius-small); background: ${({ $selected }) => - $selected ? "var(--op-item-hover-bg)" : "transparent"}; + $selected ? 'var(--op-item-hover-bg)' : 'transparent'}; cursor: pointer; padding: 0 var(--spacer-s); @@ -38,21 +38,21 @@ const EmptyState = styled.div` const MAX_RESULTS = 5; export function createHashWpMenuComponent( - resultsRef: RefObject, -): FC> { - const HashWpMenuComponent: FC> = ({ + resultsRef:RefObject, +):FC> { + const HashWpMenuComponent:FC> = ({ items, selectedIndex, onItemClick, }) => { const { t } = useTranslation(); - const searchQuery = items[0]?.title ?? ""; + const searchQuery = items[0]?.title ?? ''; const visibleResults = (resultsRef.current ?? []).slice(0, MAX_RESULTS); if (!searchQuery) { return ( - {t("hashMenu.typeToSearch")} + {t('hashMenu.typeToSearch')} ); } @@ -60,7 +60,7 @@ export function createHashWpMenuComponent( if (visibleResults.length === 0) { return ( - {t("hashMenu.noResults", { query: searchQuery })} + {t('hashMenu.noResults', { query: searchQuery })} ); } @@ -85,6 +85,6 @@ export function createHashWpMenuComponent( ); }; - HashWpMenuComponent.displayName = "HashWpMenu"; + HashWpMenuComponent.displayName = 'HashWpMenu'; return HashWpMenuComponent; } \ No newline at end of file diff --git a/lib/components/HashMenu/editorUtils.ts b/lib/components/HashMenu/editorUtils.ts index 9144f15..5aabdcf 100644 --- a/lib/components/HashMenu/editorUtils.ts +++ b/lib/components/HashMenu/editorUtils.ts @@ -1,11 +1,19 @@ -import type { BlockNoteEditor } from "@blocknote/core"; -import type { InlineWpSize } from "../WorkPackage/types"; -import { makeInstanceId } from "../../utils/id.ts"; -import type { WorkPackage } from "../../openProjectTypes"; -import { placeCursorAfterInlineNode } from "../../utils/cursor.ts"; +import type { BlockNoteEditor } from '@blocknote/core'; +import type { InlineWpSize } from '../WorkPackage/types'; +import { makeInstanceId } from '../../utils/id.ts'; +import type { WorkPackage } from '../../openProjectTypes'; +import { placeCursorAfterInlineNode } from '../../utils/cursor.ts'; +// eslint-disable-next-line @typescript-eslint/no-explicit-any export type AnyEditor = BlockNoteEditor; +interface RawNode { + type:string; + text?:string; + props?:Record; + [key:string]:unknown; +} + /** * Determines the inline chip size based on how many `#` characters * the user typed before the search query in the current block. @@ -14,38 +22,38 @@ export type AnyEditor = BlockNoteEditor; * ##query -> "xs" (ID + Type + Subject) * ###query -> "s" (ID + Type + Status + Subject) */ -export function getSizeFromCurrentBlock(editor: AnyEditor): InlineWpSize { +export function getSizeFromCurrentBlock(editor:AnyEditor):InlineWpSize { const block = editor.getTextCursorPosition()?.block; - if (!block) return "xxs"; + if (!block) return 'xxs'; - const content = (block.content ?? []) as any[]; - let lastHashCount: number | null = null; + const content = (block.content ?? []) as RawNode[]; + let lastHashCount:number | null = null; for (const node of content) { - if (node.type !== "text") continue; - const text = node.text as string; + if (node.type !== 'text') continue; + const text = node.text ?? ''; const matches = [...text.matchAll(/#+/g)]; if (matches.length > 0) { lastHashCount = matches[matches.length - 1][0].length; } } - if (lastHashCount === null) return "xxs"; - if (lastHashCount >= 3) return "s"; - if (lastHashCount === 2) return "xs"; - return "xxs"; + if (lastHashCount === null) return 'xxs'; + if (lastHashCount >= 3) return 's'; + if (lastHashCount === 2) return 'xs'; + return 'xxs'; } /** * Inserts a chip at the cursor, removes the `#query` trigger before it, * then repositions the cursor right after the chip via its instanceId. */ -export function insertWpChip(editor: AnyEditor, wp: WorkPackage, size: InlineWpSize): void { +export function insertWpChip(editor:AnyEditor, wp:WorkPackage, size:InlineWpSize):void { const instanceId = makeInstanceId(); - (editor.insertInlineContent as (content: unknown[]) => void)([ - { type: "openProjectWorkPackageInline", props: { wpid: String(wp.id), instanceId, size } }, - { type: "text", text: " ", styles: {} }, + (editor.insertInlineContent as (content:unknown[]) => void)([ + { type: 'openProjectWorkPackageInline', props: { wpid: String(wp.id), instanceId, size } }, + { type: 'text', text: ' ', styles: {} }, ]); removeTriggerBeforeChip(editor, instanceId); @@ -60,23 +68,23 @@ export function insertWpChip(editor: AnyEditor, wp: WorkPackage, size: InlineWpS * Trims the trailing `#query` from the text node right before the chip. * Returns the block ID if the operation completed (with or without changes), or null if no block. */ -export function removeTriggerBeforeChip(editor: AnyEditor, instanceId: string): string | null { +export function removeTriggerBeforeChip(editor:AnyEditor, instanceId:string):string | null { const block = editor.getTextCursorPosition()?.block; if (!block) return null; - const content = (block.content ?? []) as any[]; + const content = (block.content ?? []) as RawNode[]; const chipIndex = content.findIndex( - (n) => n.type === "openProjectWorkPackageInline" && n.props?.instanceId === instanceId + (n) => n.type === 'openProjectWorkPackageInline' && n.props?.instanceId === instanceId ); if (chipIndex <= 0) return block.id; const prev = content[chipIndex - 1]; - if (prev.type !== "text") return block.id; + if (prev.type !== 'text') return block.id; - const match = (prev.text as string).match(/#+\S*$/); + const match = /#+\S*$/.exec(prev.text ?? ''); if (!match) return block.id; - const before = (prev.text as string).slice(0, match.index); + const before = (prev.text ?? '').slice(0, match.index); const newContent = [...content]; if (before.length > 0) { @@ -85,6 +93,7 @@ export function removeTriggerBeforeChip(editor: AnyEditor, instanceId: string): newContent.splice(chipIndex - 1, 1); } + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument editor.updateBlock(block.id, { content: newContent } as any); return block.id; -} \ No newline at end of file +} diff --git a/lib/components/HashMenu/index.ts b/lib/components/HashMenu/index.ts index f147c89..c88e5ac 100644 --- a/lib/components/HashMenu/index.ts +++ b/lib/components/HashMenu/index.ts @@ -1,4 +1,4 @@ -export { createHashWpMenuComponent } from "./HashWpMenu"; -export { useHashWpMenu } from "./useHashWpMenu"; -export { isHashWpQuery } from "./types"; -export type { HashMenuItem } from "./types"; \ No newline at end of file +export { createHashWpMenuComponent } from './HashWpMenu'; +export { useHashWpMenu } from './useHashWpMenu'; +export { isHashWpQuery } from './types'; +export type { HashMenuItem } from './types'; \ No newline at end of file diff --git a/lib/components/HashMenu/types.ts b/lib/components/HashMenu/types.ts index 2b2d3bb..4c99b6c 100644 --- a/lib/components/HashMenu/types.ts +++ b/lib/components/HashMenu/types.ts @@ -1,8 +1,8 @@ export interface HashMenuItem { - title: string; - onItemClick: () => void; + title:string; + onItemClick:() => void; } -export function isHashWpQuery(query: string): boolean { +export function isHashWpQuery(query:string):boolean { return query.trim().length > 0; } \ No newline at end of file diff --git a/lib/components/HashMenu/useHashWpMenu.ts b/lib/components/HashMenu/useHashWpMenu.ts index d08f86b..589511b 100644 --- a/lib/components/HashMenu/useHashWpMenu.ts +++ b/lib/components/HashMenu/useHashWpMenu.ts @@ -1,19 +1,19 @@ -import { useCallback, useMemo, useRef } from "react"; -import { useWorkPackageSearch } from "../../hooks/useWorkPackageSearch"; -import { createHashWpMenuComponent } from "./HashWpMenu"; -import { isHashWpQuery } from "./types"; -import { getSizeFromCurrentBlock, insertWpChip } from "./editorUtils"; -import type { HashMenuItem } from "./types"; -import type { AnyEditor } from "./editorUtils"; -import type { WorkPackage } from "../../openProjectTypes"; -import { cacheColors } from "../../services/colors"; +import { useCallback, useMemo, useRef } from 'react'; +import { useWorkPackageSearch } from '../../hooks/useWorkPackageSearch'; +import { createHashWpMenuComponent } from './HashWpMenu'; +import { isHashWpQuery } from './types'; +import { getSizeFromCurrentBlock, insertWpChip } from './editorUtils'; +import type { HashMenuItem } from './types'; +import type { AnyEditor } from './editorUtils'; +import type { WorkPackage } from '../../openProjectTypes'; +import { cacheColors } from '../../services/colors'; -export function useHashWpMenu(editor: AnyEditor) { +export function useHashWpMenu(editor:AnyEditor) { const { search } = useWorkPackageSearch(); const searchResultsRef = useRef([]); const getHashItems = useCallback( - async (query: string): Promise => { + async (query:string):Promise => { if (!isHashWpQuery(query)) return []; await cacheColors(); @@ -32,10 +32,12 @@ export function useHashWpMenu(editor: AnyEditor) { [editor, search] ); + /* eslint-disable react-hooks/refs */ const HashWpMenu = useMemo( () => createHashWpMenuComponent(searchResultsRef), [] ); + /* eslint-enable react-hooks/refs */ return { getHashItems, HashWpMenu }; } \ No newline at end of file diff --git a/lib/components/InlineWorkPackage/InlineChips.tsx b/lib/components/InlineWorkPackage/InlineChips.tsx index 0a0e346..b0f97be 100644 --- a/lib/components/InlineWorkPackage/InlineChips.tsx +++ b/lib/components/InlineWorkPackage/InlineChips.tsx @@ -1,42 +1,42 @@ -import React from "react"; -import type { WorkPackage } from "../../openProjectTypes"; -import { linkToWorkPackage } from "../../services/openProjectApi"; +import React from 'react'; +import type { WorkPackage } from '../../openProjectTypes'; +import { linkToWorkPackage } from '../../services/openProjectApi'; import { typeColor, statusColor, statusBorderColor, statusTextColor, statusBackgroundColor, -} from "../../services/colors"; -import { ChipBaseXXS, ChipBaseXS, ChipBaseS } from "./chipLayouts"; +} from '../../services/colors'; +import { ChipBaseXXS, ChipBaseXS, ChipBaseS } from './chipLayouts'; import { WorkPackageId, WorkPackageType, WorkPackageStatus, WorkPackageTitleLink, -} from "../WorkPackage/atoms"; -import { formatWorkPackageId } from "../../utils/id"; +} from '../WorkPackage/atoms'; +import { formatWorkPackageId } from '../../utils/id'; -const resolvedDisplayId = (wp: WorkPackage) => wp.displayId ?? String(wp.id); +const resolvedDisplayId = (wp:WorkPackage) => wp.displayId ?? String(wp.id); -const titleLinkProps = (wp: WorkPackage) => ({ - as: "a" as const, +const titleLinkProps = (wp:WorkPackage) => ({ + as: 'a' as const, href: linkToWorkPackage(resolvedDisplayId(wp)), - target: "_blank" as const, - rel: "noopener noreferrer", + target: '_blank' as const, + rel: 'noopener noreferrer', $compact: true, - onClick: (e: React.MouseEvent) => e.stopPropagation(), + onClick: (e:React.MouseEvent) => e.stopPropagation(), }); // XXS — "#ID" (padding 2px 8px) -export const WpChipXXS = ({ wp }: { wp: WorkPackage }) => ( +export const WpChipXXS = ({ wp }:{ wp:WorkPackage }) => ( {formatWorkPackageId(resolvedDisplayId(wp))} ); // XS — "#ID TYPE [Title]" (padding 8px) -export const WpChipXS = ({ wp }: { wp: WorkPackage }) => ( +export const WpChipXS = ({ wp }:{ wp:WorkPackage }) => ( {formatWorkPackageId(resolvedDisplayId(wp))} {wp._links?.type?.title && ( @@ -51,7 +51,7 @@ export const WpChipXS = ({ wp }: { wp: WorkPackage }) => ( ); // S — "#ID TYPE [Status] [Title]" (padding 8px) -export const WpChipS = ({ wp }: { wp: WorkPackage }) => ( +export const WpChipS = ({ wp }:{ wp:WorkPackage }) => ( {formatWorkPackageId(resolvedDisplayId(wp))} {wp._links?.type?.title && ( diff --git a/lib/components/InlineWorkPackage/InlineWorkPackageChip.tsx b/lib/components/InlineWorkPackage/InlineWorkPackageChip.tsx index 7a555b0..cd10a41 100644 --- a/lib/components/InlineWorkPackage/InlineWorkPackageChip.tsx +++ b/lib/components/InlineWorkPackage/InlineWorkPackageChip.tsx @@ -1,32 +1,33 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import styled from "styled-components"; -import { useWorkPackage } from "../../hooks/useWorkPackage"; -import { useColors } from "../../services/colors"; -import { CHIP_STYLES } from "../WorkPackage/tokens"; -import { ChipBase } from "./chipLayouts"; -import { WorkPackageId } from "../WorkPackage/atoms"; -import { WpChipXXS, WpChipXS, WpChipS } from "./InlineChips"; -import { WorkPackageSearchPopover } from "../Search/WorkPackageSearchPopover"; -import { WpOptionsPopover } from "../WorkPackage/OptionsPopover"; -import { getPendingCallbacks, clearInlineWpCallbacks } from "./callbacks"; -import type { InlineWpSize } from "../WorkPackage/types"; -import { wpBridge } from "../../services/wpBridge"; -import { BlockCard } from "../BlockWorkPackage/BlockCard"; -import { useTranslation } from "react-i18next"; -import { defaultWpVariables } from "../WorkPackage/atoms"; -import { formatWorkPackageId } from "../../utils/id"; -import { useIsNodeInSelection } from "../../hooks/useIsNodeInSelection"; +import { useCallback, useEffect, useRef, useState } from 'react'; +import styled from 'styled-components'; +import { useWorkPackage } from '../../hooks/useWorkPackage'; +import { useColors } from '../../services/colors'; +import { CHIP_STYLES } from '../WorkPackage/tokens'; +import { ChipBase } from './chipLayouts'; +import { WorkPackageId } from '../WorkPackage/atoms'; +import { WpChipXXS, WpChipXS, WpChipS } from './InlineChips'; +import { WorkPackageSearchPopover } from '../Search/WorkPackageSearchPopover'; +import { WpOptionsPopover } from '../WorkPackage/OptionsPopover'; +import { getPendingCallbacks, clearInlineWpCallbacks } from './callbacks'; +import type { InlineWpSize } from '../WorkPackage/types'; +import { wpBridge } from '../../services/wpBridge'; +import { BlockCard } from '../BlockWorkPackage/BlockCard'; +import { useTranslation } from 'react-i18next'; +import { defaultWpVariables } from '../WorkPackage/atoms'; +import { formatWorkPackageId } from '../../utils/id'; +import { useIsNodeInSelection } from '../../hooks/useIsNodeInSelection'; interface InlineWorkPackageChipProps { - inlineContent: { props: { wpid: string; size: string; instanceId: string } }; - contentRef: (node: HTMLElement | null) => void; - editor?: any; + inlineContent:{ props:{ wpid:string; size:string; instanceId:string } }; + contentRef:(node:HTMLElement | null) => void; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + editor?:any; } const InlineChip = styled.span.attrs({ - className: "op-bn-inline-wp", + className: 'op-bn-inline-wp', contentEditable: false, -})<{ selected?: boolean }>` +})<{ selected?:boolean }>` ${defaultWpVariables} display: inline-flex; align-items: center; @@ -34,18 +35,18 @@ const InlineChip = styled.span.attrs({ cursor: pointer; user-select: none; border-radius: ${CHIP_STYLES.radius}; - outline: ${({ selected }) => (selected ? CHIP_STYLES.focusOutline : "none")}; + outline: ${({ selected }) => (selected ? CHIP_STYLES.focusOutline : 'none')}; outline-offset: 1px; - box-shadow: ${({ selected }) => (selected ? CHIP_STYLES.focusShadow : "none")}; + box-shadow: ${({ selected }) => (selected ? CHIP_STYLES.focusShadow : 'none')}; position: relative; max-width: 100%; line-height: 1; `; -export const InlineWorkPackageChip = ({ inlineContent, contentRef, editor }: InlineWorkPackageChipProps) => { +export const InlineWorkPackageChip = ({ inlineContent, contentRef, editor }:InlineWorkPackageChipProps) => { const { t } = useTranslation(); const rawWpid = inlineContent.props.wpid; - const size = (inlineContent.props.size ?? "s") as InlineWpSize; + const size = (inlineContent.props.size ?? 's') as InlineWpSize; const instanceId = inlineContent.props.instanceId; const pendingCallbacks = getPendingCallbacks(rawWpid); @@ -58,9 +59,10 @@ export const InlineWorkPackageChip = ({ inlineContent, contentRef, editor }: Inl const [isSelected, setIsSelected] = useState(false); const chipRef = useRef(null); + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument const isEditorSelected = useIsNodeInSelection(chipRef, editor); - const setRef = (node: HTMLElement | null) => { + const setRef = (node:HTMLElement | null) => { chipRef.current = node; contentRef(node); }; @@ -68,26 +70,26 @@ export const InlineWorkPackageChip = ({ inlineContent, contentRef, editor }: Inl // Close the options popover when the user clicks outside the chip useEffect(() => { if (!isSelected) return; - const onClickOutside = (e: MouseEvent) => { + const onClickOutside = (e:MouseEvent) => { if (chipRef.current && !chipRef.current.contains(e.target as Node)) { setIsSelected(false); } }; - document.addEventListener("mousedown", onClickOutside); - return () => document.removeEventListener("mousedown", onClickOutside); + document.addEventListener('mousedown', onClickOutside); + return () => document.removeEventListener('mousedown', onClickOutside); }, [isSelected]); const handleCopy = useCallback( - (e: ClipboardEvent) => { + (e:ClipboardEvent) => { if (!isSelected || !wp || !wpid) return; e.preventDefault(); e.stopPropagation(); const formattedId = formatWorkPackageId(wp?.displayId ?? String(wpid)); - e.clipboardData?.setData("text/plain", formattedId); + e.clipboardData?.setData('text/plain', formattedId); e.clipboardData?.setData( - "text/html", + 'text/html', `${formattedId}`, ); }, @@ -96,8 +98,8 @@ export const InlineWorkPackageChip = ({ inlineContent, contentRef, editor }: Inl useEffect(() => { if (!isSelected) return; - document.addEventListener("copy", handleCopy); - return () => document.removeEventListener("copy", handleCopy); + document.addEventListener('copy', handleCopy); + return () => document.removeEventListener('copy', handleCopy); }, [isSelected, handleCopy]); // Pending: waiting for user to pick a WP via search @@ -135,7 +137,7 @@ export const InlineWorkPackageChip = ({ inlineContent, contentRef, editor }: Inl return ( { @@ -144,15 +146,16 @@ export const InlineWorkPackageChip = ({ inlineContent, contentRef, editor }: Inl setIsSelected((prev) => !prev); }} > - {size === "xxs" && } - {size === "xs" && } - {size === "s" && } + {size === 'xxs' && } + {size === 'xs' && } + {size === 's' && } {isSelected && ( setIsSelected(false)} onResize={(newSize) => { diff --git a/lib/components/InlineWorkPackage/callbacks.ts b/lib/components/InlineWorkPackage/callbacks.ts index 68f9f82..38c3da9 100644 --- a/lib/components/InlineWorkPackage/callbacks.ts +++ b/lib/components/InlineWorkPackage/callbacks.ts @@ -6,38 +6,38 @@ // and calls `onSelect` / `onCancel` when the user picks a WP or dismisses. -type WpSelectedCallback = (wpid: number) => void; +type WpSelectedCallback = (wpid:number) => void; type WpCancelCallback = () => void; export interface PendingCallbacks { - onSelect: WpSelectedCallback; - onCancel: WpCancelCallback; + onSelect:WpSelectedCallback; + onCancel:WpCancelCallback; } -const PENDING_PREFIX = "pending:" as const; +const PENDING_PREFIX = 'pending:' as const; const registry = new Map(); -export function makePendingWpid(instanceId: string): string { +export function makePendingWpid(instanceId:string):string { return `${PENDING_PREFIX}${instanceId}`; } // Returns callbacks if wpid is a pending placeholder, undefined otherwise. -export function getPendingCallbacks(wpid: string): PendingCallbacks | undefined { +export function getPendingCallbacks(wpid:string):PendingCallbacks | undefined { if (!wpid.startsWith(PENDING_PREFIX)) return undefined; return registry.get(wpid.slice(PENDING_PREFIX.length)); } export function registerInlineWpCallbacks( - key: string, - onSelect: WpSelectedCallback, - onCancel: WpCancelCallback, -): void { - if (process.env.NODE_ENV !== "production" && registry.has(key)) { + key:string, + onSelect:WpSelectedCallback, + onCancel:WpCancelCallback, +):void { + if (process.env.NODE_ENV !== 'production' && registry.has(key)) { console.warn(`[inline-wp] Overwriting existing callbacks for key "${key}". This is likely a bug.`); } registry.set(key, { onSelect, onCancel }); } -export function clearInlineWpCallbacks(key: string): void { +export function clearInlineWpCallbacks(key:string):void { registry.delete(key); } \ No newline at end of file diff --git a/lib/components/InlineWorkPackage/chipLayouts.tsx b/lib/components/InlineWorkPackage/chipLayouts.tsx index 3064841..950fa2b 100644 --- a/lib/components/InlineWorkPackage/chipLayouts.tsx +++ b/lib/components/InlineWorkPackage/chipLayouts.tsx @@ -1,6 +1,6 @@ -import { css } from "styled-components"; -import styled from "styled-components"; -import { CHIP_STYLES } from "../WorkPackage/tokens"; +import { css } from 'styled-components'; +import styled from 'styled-components'; +import { CHIP_STYLES } from '../WorkPackage/tokens'; const chipBaseStyles = css` display: inline-flex; diff --git a/lib/components/InlineWorkPackage/index.ts b/lib/components/InlineWorkPackage/index.ts index 41d1b51..9623d80 100644 --- a/lib/components/InlineWorkPackage/index.ts +++ b/lib/components/InlineWorkPackage/index.ts @@ -1,3 +1,3 @@ -export { openProjectWorkPackageInlineSpec } from "./spec"; -export { registerInlineWpCallbacks, clearInlineWpCallbacks } from "./callbacks"; -export type { InlineWpSize } from "../WorkPackage/types"; \ No newline at end of file +export { openProjectWorkPackageInlineSpec } from './spec'; +export { registerInlineWpCallbacks, clearInlineWpCallbacks } from './callbacks'; +export type { InlineWpSize } from '../WorkPackage/types'; \ No newline at end of file diff --git a/lib/components/InlineWorkPackage/spec.tsx b/lib/components/InlineWorkPackage/spec.tsx index 446baf1..c619eeb 100644 --- a/lib/components/InlineWorkPackage/spec.tsx +++ b/lib/components/InlineWorkPackage/spec.tsx @@ -1,15 +1,15 @@ -import { createReactInlineContentSpec } from "@blocknote/react"; -import { InlineWorkPackageChip } from "./InlineWorkPackageChip"; +import { createReactInlineContentSpec } from '@blocknote/react'; +import { InlineWorkPackageChip } from './InlineWorkPackageChip'; export const openProjectWorkPackageInlineSpec = createReactInlineContentSpec( { - type: "openProjectWorkPackageInline" as const, + type: 'openProjectWorkPackageInline' as const, propSchema: { - wpid: { default: "" }, - instanceId: { default: "" }, - size: { default: "s" }, + wpid: { default: '' }, + instanceId: { default: '' }, + size: { default: 's' }, }, - content: "none", + content: 'none', }, { render: ({ inlineContent, contentRef, editor }) => ( @@ -18,7 +18,7 @@ export const openProjectWorkPackageInlineSpec = createReactInlineContentSpec( toExternalHTML: ({ inlineContent }) => { const { wpid, instanceId, size } = inlineContent.props; - if (!wpid || wpid.startsWith("pending:")) return <>; + if (!wpid || wpid.startsWith('pending:')) return <>; return ( { - if (element.getAttribute("data-inline-content-type") !== "openProjectWorkPackageInline") { + if (element.getAttribute('data-inline-content-type') !== 'openProjectWorkPackageInline') { return undefined; } return { - wpid: element.getAttribute("data-wpid") ?? "", - instanceId: element.getAttribute("data-instance-id") ?? "", - size: element.getAttribute("data-size") ?? "s", + wpid: element.getAttribute('data-wpid') ?? '', + instanceId: element.getAttribute('data-instance-id') ?? '', + size: element.getAttribute('data-size') ?? 's', }; }, } diff --git a/lib/components/Search/SearchContainer.tsx b/lib/components/Search/SearchContainer.tsx index 70c4599..aff32b2 100644 --- a/lib/components/Search/SearchContainer.tsx +++ b/lib/components/Search/SearchContainer.tsx @@ -1,7 +1,7 @@ -import styled from "styled-components"; -import { defaultWpVariables } from "../WorkPackage/atoms"; +import styled from 'styled-components'; +import { defaultWpVariables } from '../WorkPackage/atoms'; -export const SEARCH_INPUT_ID = "op-bn-wp-search-input"; +export const SEARCH_INPUT_ID = 'op-bn-wp-search-input'; /** * Container for the work package search UI. @@ -11,15 +11,15 @@ export const SEARCH_INPUT_ID = "op-bn-wp-search-input"; * Without $floating it renders as a normal block element (used in BlockWorkPackage). */ export const SearchContainer = styled.div.attrs({ - className: "op-bn-search", -})<{ $floating?: boolean }>` + className: 'op-bn-search', +})<{ $floating?:boolean }>` ${defaultWpVariables} - position: ${({ $floating }) => ($floating ? "absolute" : "relative")}; - z-index: ${({ $floating }) => ($floating ? 9999 : "auto")}; - top: ${({ $floating }) => ($floating ? "1.6em" : "auto")}; - left: ${({ $floating }) => ($floating ? 0 : "auto")}; - overflow: ${({ $floating }) => ($floating ? "hidden" : "visible")}; - width: ${({ $floating }) => ($floating ? "400px" : "100%")}; + position: ${({ $floating }) => ($floating ? 'absolute' : 'relative')}; + z-index: ${({ $floating }) => ($floating ? 9999 : 'auto')}; + top: ${({ $floating }) => ($floating ? '1.6em' : 'auto')}; + left: ${({ $floating }) => ($floating ? 0 : 'auto')}; + overflow: ${({ $floating }) => ($floating ? 'hidden' : 'visible')}; + width: ${({ $floating }) => ($floating ? '400px' : '100%')}; padding: var(--spacer-m) var(--spacer-xl); background-color: var(--bn-colors-menu-background, #fff); box-shadow: var(--bn-shadow-medium); @@ -27,7 +27,7 @@ export const SearchContainer = styled.div.attrs({ line-height: 1.5; @media (min-width: 1120px) { - width: ${({ $floating }) => ($floating ? "400px" : "500px")}; + width: ${({ $floating }) => ($floating ? '400px' : '500px')}; } `; @@ -36,7 +36,7 @@ export const SearchContainer = styled.div.attrs({ * Linked to SearchInput via htmlFor / id = SEARCH_INPUT_ID. */ export const SearchLabel = styled.label.attrs({ - className: "op-bn-search--label", + className: 'op-bn-search--label', htmlFor: SEARCH_INPUT_ID, })` font-weight: normal !important; @@ -70,15 +70,15 @@ export const DropdownList = styled.div` `; export const DropdownItem = styled.div.attrs<{ - $selected: boolean; - "data-testid"?: string; + $selected:boolean; + 'data-testid'?:string; }>({ - "data-testid": "dropdown-item", + 'data-testid': 'dropdown-item', })<{ - $selected: boolean; + $selected:boolean; }>` background-color: ${({ $selected }) => - $selected ? "var(--op-item-hover-bg)" : "transparent"}; + $selected ? 'var(--op-item-hover-bg)' : 'transparent'}; &:hover { background: var(--op-item-hover-bg); } diff --git a/lib/components/Search/SearchDropdown.tsx b/lib/components/Search/SearchDropdown.tsx index 5c81221..4fe4629 100644 --- a/lib/components/Search/SearchDropdown.tsx +++ b/lib/components/Search/SearchDropdown.tsx @@ -1,23 +1,23 @@ -import { useEffect, useRef } from "react"; -import { useTranslation } from "react-i18next"; -import { SearchIcon } from "@primer/octicons-react"; -import styled from "styled-components"; -import type { WorkPackage } from "../../openProjectTypes"; -import { useWorkPackageSearchDropdown } from "../../hooks/useWorkPackageSearchDropdown"; +import { useEffect, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { SearchIcon } from '@primer/octicons-react'; +import styled from 'styled-components'; +import type { WorkPackage } from '../../openProjectTypes'; +import { useWorkPackageSearchDropdown } from '../../hooks/useWorkPackageSearchDropdown'; import { SearchIconWrapper, SearchInput, DropdownList, DropdownItem, -} from "./SearchContainer"; +} from './SearchContainer'; const MAX_RESULTS = 5; interface SearchDropdownProps { - onSelect: (wp: WorkPackage) => void; - onCancel?: () => void; - autoFocus?: boolean; - renderItem: (wp: WorkPackage) => React.ReactNode; + onSelect:(wp:WorkPackage) => void; + onCancel?:() => void; + autoFocus?:boolean; + renderItem:(wp:WorkPackage) => React.ReactNode; } const SearchInputWrapper = styled.div` @@ -48,7 +48,7 @@ const SearchInputWithIcon = styled(SearchInput)` } `; -export const SearchDropdown = ({ onSelect, onCancel, autoFocus, renderItem }: SearchDropdownProps) => { +export const SearchDropdown = ({ onSelect, onCancel, autoFocus, renderItem }:SearchDropdownProps) => { const { t } = useTranslation(); const inputRef = useRef(null); const blurTimerRef = useRef | undefined>(undefined); @@ -77,7 +77,7 @@ export const SearchDropdown = ({ onSelect, onCancel, autoFocus, renderItem }: Se handleKeyDown, } = useWorkPackageSearchDropdown({ onSelect, - onEscape: onCancel ?? (() => {}), + onEscape: onCancel ?? (() => undefined), }); return ( @@ -92,7 +92,7 @@ export const SearchDropdown = ({ onSelect, onCancel, autoFocus, renderItem }: Se type="search" autoComplete="off" spellCheck={false} - placeholder={t("search.placeholder")} + placeholder={t('search.placeholder')} value={searchQuery} onChange={(e) => { setSearchQuery(e.target.value); @@ -117,7 +117,7 @@ export const SearchDropdown = ({ onSelect, onCancel, autoFocus, renderItem }: Se {isDropdownOpen && searchResults.length > 0 && ( - + {searchResults.slice(0, MAX_RESULTS).map((wp, index) => ( void; - onCancel: () => void; - renderItem: (wp: WorkPackage) => React.ReactNode; + onSelect:(wp:WorkPackage) => void; + onCancel:() => void; + renderItem:(wp:WorkPackage) => React.ReactNode; } // Floating search popover for inline work package chip. @@ -14,7 +14,7 @@ export const WorkPackageSearchPopover = ({ onSelect, onCancel, renderItem, -}: WorkPackageSearchPopoverProps) => { +}:WorkPackageSearchPopoverProps) => { const { t } = useTranslation(); return ( @@ -23,7 +23,7 @@ export const WorkPackageSearchPopover = ({ className="op-bn-inline-search" onMouseDown={(e) => e.stopPropagation()} > - {t("search.label")} + {t('search.label')} ) => ( +export const ShadowDomWrapper = ({ children, target }:PropsWithChildren) => ( {children} diff --git a/lib/components/SlashMenu.tsx b/lib/components/SlashMenu.tsx index eee991f..c108ea2 100644 --- a/lib/components/SlashMenu.tsx +++ b/lib/components/SlashMenu.tsx @@ -1,52 +1,57 @@ -import type { BlockNoteEditor, InlineContentFromConfig } from "@blocknote/core"; -import { LinkIcon } from "@primer/octicons-react"; -import i18n from "../services/i18n.ts"; -import { getAliases } from "../services/slashMenuAliases"; -import { registerInlineWpCallbacks, clearInlineWpCallbacks, makePendingWpid } from "./InlineWorkPackage/callbacks"; -import { makeInstanceId } from "../utils/id.ts"; -import { placeCursorAfterInlineNode } from "../utils/cursor.ts"; - -type AnyEditor = BlockNoteEditor; +import type { BlockNoteEditor, InlineContentFromConfig } from '@blocknote/core'; +import { LinkIcon } from '@primer/octicons-react'; +import i18n from '../services/i18n.ts'; +import { getAliases } from '../services/slashMenuAliases'; +import { registerInlineWpCallbacks, clearInlineWpCallbacks, makePendingWpid } from './InlineWorkPackage/callbacks'; +import { makeInstanceId } from '../utils/id.ts'; +import { placeCursorAfterInlineNode } from '../utils/cursor.ts'; +import type { AnyEditor } from './HashMenu/editorUtils'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any type AnyInlineNode = InlineContentFromConfig; function getBlockContent( - editor: AnyEditor, - blockId: string -): { blockId: string; content: AnyInlineNode[] } | null { - const block = editor.getBlock(blockId) as { id: string; content: AnyInlineNode[] } | null; + editor:AnyEditor, + blockId:string +):{ blockId:string; content:AnyInlineNode[] } | null { + const block = editor.getBlock(blockId) as { id:string; content:AnyInlineNode[] } | null; if (!block) return null; return { blockId: block.id, content: block.content ?? [] }; } function buildOnSelect( - editor: AnyEditor, - blockId: string, - pendingWpid: string, - instanceId: string -): (wpid: number) => void { - return (wpid: number) => { + editor:AnyEditor, + blockId:string, + pendingWpid:string, + instanceId:string +):(wpid:number) => void { + return (wpid:number) => { const current = getBlockContent(editor, blockId); if (!current) return; const chipIndex = current.content.findIndex((node) => { - const n = node as { type: string; props?: { wpid?: string } }; - return n.type === "openProjectWorkPackageInline" && n.props?.wpid === pendingWpid; + const n = node as { type:string; props?:{ wpid?:string } }; + return n.type === 'openProjectWorkPackageInline' && n.props?.wpid === pendingWpid; }); const updatedContent = current.content.map((node) => { - const n = node as { type: string; props?: { wpid?: string; instanceId?: string } }; - if (n.type === "openProjectWorkPackageInline" && n.props?.wpid === pendingWpid) { + const n = node as { type:string; props?:{ wpid?:string; instanceId?:string } }; + if (n.type === 'openProjectWorkPackageInline' && n.props?.wpid === pendingWpid) { return { ...n, props: { ...n.props, wpid: String(wpid), instanceId } }; } return node; }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment const nodeAfter = updatedContent[chipIndex + 1] as any; - const hasSpaceAfter = nodeAfter?.type === "text" && nodeAfter?.text?.startsWith(" "); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call + const hasSpaceAfter = nodeAfter?.type === 'text' && nodeAfter?.text?.startsWith(' '); if (!hasSpaceAfter) { - updatedContent.splice(chipIndex + 1, 0, { type: "text", text: " ", styles: {} } as any); + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument + updatedContent.splice(chipIndex + 1, 0, { type: 'text', text: ' ', styles: {} } as any); } + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument editor.updateBlock(current.blockId, { content: updatedContent } as any); requestAnimationFrame(() => { @@ -57,26 +62,27 @@ function buildOnSelect( } function buildOnCancel( - editor: AnyEditor, - blockId: string, - pendingWpid: string, - instanceId: string -): () => void { + editor:AnyEditor, + blockId:string, + pendingWpid:string, + instanceId:string +):() => void { return () => { const current = getBlockContent(editor, blockId); if (!current) return; const updatedContent = current.content.filter((node) => { - const n = node as { type: string; props?: { wpid?: string } }; - return !(n.type === "openProjectWorkPackageInline" && n.props?.wpid === pendingWpid); + const n = node as { type:string; props?:{ wpid?:string } }; + return !(n.type === 'openProjectWorkPackageInline' && n.props?.wpid === pendingWpid); }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument editor.updateBlock(current.blockId, { content: updatedContent } as any); clearInlineWpCallbacks(instanceId); }; } -function handleInlineWorkPackageClick(editor: AnyEditor): void { +function handleInlineWorkPackageClick(editor:AnyEditor):void { const instanceId = makeInstanceId(); const pendingWpid = makePendingWpid(instanceId); @@ -89,21 +95,22 @@ function handleInlineWorkPackageClick(editor: AnyEditor): void { registerInlineWpCallbacks(instanceId, onSelect, onCancel); try { - (editor.insertInlineContent as (content: unknown[]) => void)([ - { type: "openProjectWorkPackageInline", props: { wpid: pendingWpid, instanceId, size: "s" } }, - " ", + (editor.insertInlineContent as (content:unknown[]) => void)([ + { type: 'openProjectWorkPackageInline', props: { wpid: pendingWpid, instanceId, size: 's' } }, + ' ', ]); } catch (error) { - console.error("[inline-wp] insertInlineContent failed:", error); + console.error('[inline-wp] insertInlineContent failed:', error); clearInlineWpCallbacks(instanceId); } } -export const workPackageSlashMenu = (editor: BlockNoteEditor) => ({ - title: i18n.t("slashMenu.title"), +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const workPackageSlashMenu = (editor:BlockNoteEditor) => ({ + title: i18n.t('slashMenu.title'), onItemClick: () => handleInlineWorkPackageClick(editor), aliases: [...getAliases()], - group: "OpenProject", + group: 'OpenProject', icon: , - subtext: i18n.t("slashMenu.subtext"), + subtext: i18n.t('slashMenu.subtext'), }); \ No newline at end of file diff --git a/lib/components/WorkPackage/OptionsPopover.tsx b/lib/components/WorkPackage/OptionsPopover.tsx index 8691f23..a8cb255 100644 --- a/lib/components/WorkPackage/OptionsPopover.tsx +++ b/lib/components/WorkPackage/OptionsPopover.tsx @@ -1,34 +1,150 @@ -import { useState, useEffect, type CSSProperties } from "react"; -import { createPortal } from "react-dom"; -import { useTranslation } from "react-i18next"; -import type { WorkPackage } from "../../openProjectTypes"; -import { linkToWorkPackage } from "../../services/openProjectApi"; -import type { InlineWpSize, BlockWpSize } from "./types"; -import styled from "styled-components"; -import { defaultWpVariables } from "./atoms"; +import { useState, useEffect, type CSSProperties } from 'react'; +import { createPortal } from 'react-dom'; +import { useTranslation } from 'react-i18next'; +import type { WorkPackage } from '../../openProjectTypes'; +import { linkToWorkPackage } from '../../services/openProjectApi'; +import type { InlineWpSize, BlockWpSize } from './types'; +import styled from 'styled-components'; +import { defaultWpVariables } from './atoms'; import { LinkExternalIcon, TrashIcon, ChevronDownIcon, -} from "@primer/octicons-react"; -import {formatWorkPackageId} from "../../utils/id"; +} from '@primer/octicons-react'; +import {formatWorkPackageId} from '../../utils/id'; export interface WpOptionsProps { - wp: WorkPackage; - currentSize?: InlineWpSize; - currentBlockSize?: BlockWpSize; - instanceId?: string; - anchorEl?: HTMLElement | null; - onClose: () => void; - onResize?: (size: InlineWpSize) => void; - onRemove?: () => void; - onConvertToBlock?: (size: BlockWpSize) => void; - onConvertToInline?: (size: InlineWpSize) => void; - onResizeBlock?: (size: BlockWpSize) => void; + wp:WorkPackage; + currentSize?:InlineWpSize; + currentBlockSize?:BlockWpSize; + instanceId?:string; + anchorEl?:HTMLElement | null; + onClose:() => void; + onResize?:(size:InlineWpSize) => void; + onRemove?:() => void; + onConvertToBlock?:(size:BlockWpSize) => void; + onConvertToInline?:(size:InlineWpSize) => void; + onResizeBlock?:(size:BlockWpSize) => void; } -const INLINE_SIZE_OPTIONS: InlineWpSize[] = ["xxs", "xs", "s"]; -const BLOCK_SIZE_OPTIONS: BlockWpSize[] = ["m"]; +const INLINE_SIZE_OPTIONS:InlineWpSize[] = ['xxs', 'xs', 's']; +const BLOCK_SIZE_OPTIONS:BlockWpSize[] = ['m']; + +const Popover = styled.div.attrs({ + className: 'op-bn-inline-options', + 'data-testid': 'popover-content', +})` + ${defaultWpVariables} + position: absolute; + z-index: 9999; + background-color: var(--bn-colors-menu-background, #fff); + box-shadow: var(--bn-shadow-medium); + border-radius: var(--bn-border-radius-large); + padding: var(--spacer-s); + display: flex; + align-items: center; + gap: 2px; + bottom: calc(100% + 6px); + left: 0; + white-space: nowrap; +`; + +const PopBtn = styled.button<{ $danger?:boolean }>` + background: none; + border: none; + border-radius: var(--bn-border-radius-small); + padding: var(--spacer-s) var(--spacer-m); + cursor: pointer; + font-size: 0.82em; + color: ${({ $danger }) => + $danger + ? 'var(--mantine-color-red-8)' + : 'var(--bn-colors-editor-text, #333)'}; + display: flex; + align-items: center; + gap: var(--spacer-s); + line-height: 1; + &:hover { + background-color: var( + --bn-colors-highlights-gray-background, + #f5f5f5 + ); + } + svg { flex-shrink: 0; } +`; + +const Divider = styled.div` + width: 1px; + height: 18px; + background: var(--mantine-color-default-border); + margin: 0 2px; +`; + +const SizeButtonWrapper = styled.div` + position: relative; +`; + +const SizeMenu = styled.div.attrs<{ + 'data-testid'?:string; +}>({ + 'data-testid': 'size-menu', +})` + position: absolute; + top: calc(100% + var(--spacer-s)); + left: 0; + z-index: 10000; + background: var(--bn-colors-menu-background, #fff); + box-shadow: var(--bn-shadow-medium); + border-radius: var(--bn-border-radius-large); + padding: var(--spacer-s); + min-width: 200px; +`; + +const SizeMenuLabel = styled.div` + padding: var(--spacer-s) var(--spacer-m); + font-size: 0.75em; + opacity: 0.5; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; +`; + +const SizeMenuDivider = styled.div` + height: 1px; + background: var(--mantine-color-default-border); + margin: var(--spacer-s) 0; +`; + +const SizeBtn = styled.button<{ $active?:boolean }>` + display: flex; + align-items: center; + gap: var(--spacer-m); + width: 100%; + background: ${({ $active }) => + $active + ? 'var(--bn-colors-highlights-gray-background, #f0f0f0)' + : 'none'}; + border: none; + border-radius: var(--bn-border-radius-small); + padding: var(--spacer-s) var(--spacer-m); + cursor: pointer; + font-size: 0.82em; + color: var(--bn-colors-editor-text, #333); + text-align: left; + &:hover { background: var(--bn-colors-highlights-gray-background, #f0f0f0); } +`; + +const SizeBtnLabel = styled.strong` + min-width: 28px; +`; + +const SizeBtnDesc = styled.span` + opacity: 0.6; +`; + +const IcOpen = () => ; +const IcDelete = () => ; +const IcChevron = () => ; export const WpOptionsPopover = ({ wp, @@ -42,7 +158,7 @@ export const WpOptionsPopover = ({ onConvertToBlock, onConvertToInline, onResizeBlock, -}: WpOptionsProps) => { +}:WpOptionsProps) => { const { t } = useTranslation(); const [showSizes, setShowSizes] = useState(false); @@ -56,25 +172,25 @@ export const WpOptionsPopover = ({ const handleScroll = () => onClose(); update(); - window.addEventListener("scroll", handleScroll, true); - window.addEventListener("resize", update); + window.addEventListener('scroll', handleScroll, true); + window.addEventListener('resize', update); return () => { - window.removeEventListener("scroll", handleScroll, true); - window.removeEventListener("resize", update); + window.removeEventListener('scroll', handleScroll, true); + window.removeEventListener('resize', update); }; }, [anchorEl, onClose]); - const fixedStyle: CSSProperties | undefined = anchorRect + const fixedStyle:CSSProperties | undefined = anchorRect ? { - position: "fixed", + position: 'fixed', bottom: window.innerHeight - anchorRect.top + 6, left: anchorRect.left, } : undefined; const isBlock = currentSize === undefined; - - const displayedSizeKey = isBlock ? (currentBlockSize ?? "m") : currentSize; + + const displayedSizeKey = isBlock ? (currentBlockSize ?? 'm') : currentSize; const displayedSize = t(`sizes.${displayedSizeKey}.label`); const closeMenu = () => { @@ -86,22 +202,22 @@ export const WpOptionsPopover = ({ // Prevent editor/parent handlers from stealing focus or closing the popover e.stopPropagation()}> { e.stopPropagation(); - window.open(linkToWorkPackage(wp.displayId), "_blank", "noopener,noreferrer"); + window.open(linkToWorkPackage(wp.displayId), '_blank', 'noopener,noreferrer'); }} > - {t("options.open")} + {t('options.open')} { e.stopPropagation(); setShowSizes((prev) => !prev); @@ -113,7 +229,7 @@ export const WpOptionsPopover = ({ {showSizes && ( e.stopPropagation()}> - {t("options.inlineSizeLabel")} + {t('options.inlineSizeLabel')} {INLINE_SIZE_OPTIONS.map((size) => { return ( - {t("options.blockSizeLabel")} + {t('options.blockSizeLabel')} {BLOCK_SIZE_OPTIONS.map((size) => { return ( { e.stopPropagation(); onRemove?.(); onClose(); }} > - {t("options.remove")} + {t('options.remove')} ); if (anchorEl) { - const portalTarget = (anchorEl.closest(".bn-container") as HTMLElement | null) ?? document.body; + const portalTarget = (anchorEl.closest('.bn-container')) ?? document.body; return createPortal(content, portalTarget); } return content; }; - -const Popover = styled.div.attrs({ - className: "op-bn-inline-options", - "data-testid": "popover-content", -})` - ${defaultWpVariables} - position: absolute; - z-index: 9999; - background-color: var(--bn-colors-menu-background, #fff); - box-shadow: var(--bn-shadow-medium); - border-radius: var(--bn-border-radius-large); - padding: var(--spacer-s); - display: flex; - align-items: center; - gap: 2px; - bottom: calc(100% + 6px); - left: 0; - white-space: nowrap; -`; - -const PopBtn = styled.button<{ $danger?: boolean }>` - background: none; - border: none; - border-radius: var(--bn-border-radius-small); - padding: var(--spacer-s) var(--spacer-m); - cursor: pointer; - font-size: 0.82em; - color: ${({ $danger }) => - $danger - ? "var(--mantine-color-red-8)" - : "var(--bn-colors-editor-text, #333)"}; - display: flex; - align-items: center; - gap: var(--spacer-s); - line-height: 1; - &:hover { - background-color: var( - --bn-colors-highlights-gray-background, - #f5f5f5 - ); - } - svg { flex-shrink: 0; } -`; - -const Divider = styled.div` - width: 1px; - height: 18px; - background: var(--mantine-color-default-border); - margin: 0 2px; -`; - -const SizeButtonWrapper = styled.div` - position: relative; -`; - -const SizeMenu = styled.div.attrs<{ - "data-testid"?: string; -}>({ - "data-testid": "size-menu", -})` - position: absolute; - top: calc(100% + var(--spacer-s)); - left: 0; - z-index: 10000; - background: var(--bn-colors-menu-background, #fff); - box-shadow: var(--bn-shadow-medium); - border-radius: var(--bn-border-radius-large); - padding: var(--spacer-s); - min-width: 200px; -`; - -const SizeMenuLabel = styled.div` - padding: var(--spacer-s) var(--spacer-m); - font-size: 0.75em; - opacity: 0.5; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.05em; -`; - -const SizeMenuDivider = styled.div` - height: 1px; - background: var(--mantine-color-default-border); - margin: var(--spacer-s) 0; -`; - -const SizeBtn = styled.button<{ $active?: boolean }>` - display: flex; - align-items: center; - gap: var(--spacer-m); - width: 100%; - background: ${({ $active }) => - $active - ? "var(--bn-colors-highlights-gray-background, #f0f0f0)" - : "none"}; - border: none; - border-radius: var(--bn-border-radius-small); - padding: var(--spacer-s) var(--spacer-m); - cursor: pointer; - font-size: 0.82em; - color: var(--bn-colors-editor-text, #333); - text-align: left; - &:hover { background: var(--bn-colors-highlights-gray-background, #f0f0f0); } -`; - -const SizeBtnLabel = styled.strong` - min-width: 28px; -`; - -const SizeBtnDesc = styled.span` - opacity: 0.6; -`; - -const IcOpen = () => ; -const IcDelete = () => ; -const IcChevron = () => ; \ No newline at end of file diff --git a/lib/components/WorkPackage/UnavailableCard.tsx b/lib/components/WorkPackage/UnavailableCard.tsx index 0cff0d4..59ca891 100644 --- a/lib/components/WorkPackage/UnavailableCard.tsx +++ b/lib/components/WorkPackage/UnavailableCard.tsx @@ -1,24 +1,13 @@ -import styled from "styled-components"; -import { defaultWpVariables } from "./atoms"; +import styled from 'styled-components'; +import { defaultWpVariables } from './atoms'; interface UnavailableCardProps { - header: string; - message: string; + header:string; + message:string; } -export const UnavailableCard = ({ header, message }: UnavailableCardProps) => ( - - - - {header} - - {message} - - -); - const UnavailableWorkPackage = styled.div.attrs({ - className: "op-bn-work-package-placeholder", + className: 'op-bn-work-package-placeholder', })` ${defaultWpVariables} padding: var(--spacer-m) var(--spacer-l); @@ -27,14 +16,25 @@ const UnavailableWorkPackage = styled.div.attrs({ `; const UnavailableMessage = styled.div.attrs({ - className: "op-bn-unavailable-message", + className: 'op-bn-unavailable-message', })` color: var(--bn-colors-editor-text) !important; `; const UnavailableMessageHeader = styled.div.attrs({ - className: "op-bn-unavailable-message--header", + className: 'op-bn-unavailable-message--header', })` font-weight: 600; color: var(--bn-colors-editor-text) !important; -`; \ No newline at end of file +`; + +export const UnavailableCard = ({ header, message }:UnavailableCardProps) => ( + + + + {header} + + {message} + + +); diff --git a/lib/components/WorkPackage/atoms.tsx b/lib/components/WorkPackage/atoms.tsx index 9fd8a99..55ff373 100644 --- a/lib/components/WorkPackage/atoms.tsx +++ b/lib/components/WorkPackage/atoms.tsx @@ -1,8 +1,8 @@ -import styled, { css } from "styled-components"; +import styled, { css } from 'styled-components'; import { defaultColorStyles, typeTextColor, -} from "../../services/colors"; +} from '../../services/colors'; export const defaultWpVariables = css` --spacer-s: 4px; @@ -31,8 +31,8 @@ export const defaultWpVariables = css` `; export const WorkPackageId = styled.span.attrs({ - className: "op-bn-work-package--id", -})<{ $compact?: boolean }>` + className: 'op-bn-work-package--id', +})<{ $compact?:boolean }>` color: var(--op-wp-meta-color) !important; ${({ $compact }) => @@ -45,9 +45,9 @@ export const WorkPackageId = styled.span.attrs({ `; export const WorkPackageType = styled.span.attrs({ - className: "op-bn-work-package--type", - "data-testid": "op-bn-work-package--type", -})<{ $color: string; $compact?: boolean }>` + className: 'op-bn-work-package--type', + 'data-testid': 'op-bn-work-package--type', +})<{ $color:string; $compact?:boolean }>` ${({ $color }) => defaultColorStyles($color)} font-weight: ${({ $compact }) => ($compact ? 600 : 500)}; text-transform: uppercase; @@ -62,18 +62,18 @@ export const WorkPackageType = styled.span.attrs({ `; export const WorkPackageStatus = styled.span.attrs({ - className: "op-bn-work-package--status", + className: 'op-bn-work-package--status', })<{ - $baseColor: string; - $borderColor?: string; - $textColor?: string; - $bgColor?: string; - $compact?: boolean; + $baseColor:string; + $borderColor?:string; + $textColor?:string; + $bgColor?:string; + $compact?:boolean; }>` ${({ $baseColor }) => defaultColorStyles($baseColor)} font-size: 0.95em; border-radius: 100px; - border: 1px solid ${({ $borderColor }) => $borderColor ?? "transparent"}; + border: 1px solid ${({ $borderColor }) => $borderColor ?? 'transparent'}; padding: 0 7px; align-content: center; color: ${({ $textColor }) => $textColor} !important; @@ -92,8 +92,8 @@ export const WorkPackageStatus = styled.span.attrs({ `; export const WorkPackageTitle = styled.span.attrs({ - className: "op-bn-work-package--title", -})<{ $compact?: boolean }>` + className: 'op-bn-work-package--title', +})<{ $compact?:boolean }>` flex-basis: max-content; color: var(--bn-colors-editor-text); font-weight: 500; @@ -110,7 +110,7 @@ export const WorkPackageTitle = styled.span.attrs({ `} `; -export const WorkPackageTitleLink = styled.a<{ $compact?: boolean }>` +export const WorkPackageTitleLink = styled.a<{ $compact?:boolean }>` cursor: pointer; text-decoration: none; color: var(--bn-colors-highlights-blue-text); diff --git a/lib/components/WorkPackage/index.ts b/lib/components/WorkPackage/index.ts index 8651d45..2a13909 100644 --- a/lib/components/WorkPackage/index.ts +++ b/lib/components/WorkPackage/index.ts @@ -5,8 +5,8 @@ export { WorkPackageTitle, WorkPackageTitleLink, defaultWpVariables, -} from "./atoms"; -export { WpOptionsPopover } from "./OptionsPopover"; -export { UnavailableCard } from "./UnavailableCard"; -export type { WpOptionsProps } from "./OptionsPopover"; -export type { InlineWpSize, BlockWpSize, WpSize } from "./types"; \ No newline at end of file +} from './atoms'; +export { WpOptionsPopover } from './OptionsPopover'; +export { UnavailableCard } from './UnavailableCard'; +export type { WpOptionsProps } from './OptionsPopover'; +export type { InlineWpSize, BlockWpSize, WpSize } from './types'; \ No newline at end of file diff --git a/lib/components/WorkPackage/tokens.ts b/lib/components/WorkPackage/tokens.ts index 1efc080..a27ed49 100644 --- a/lib/components/WorkPackage/tokens.ts +++ b/lib/components/WorkPackage/tokens.ts @@ -1,47 +1,47 @@ export const CHIP_STYLES = { - bg: "var(--op-chip-bg)", - radius: "var(--bn-border-radius)", - gap: "6px", + bg: 'var(--op-chip-bg)', + radius: 'var(--bn-border-radius)', + gap: '6px', padding: { - xxs: "2.5px 6px", - xs: "1.5px 6px", - s: "1.5px 6px", + xxs: '2.5px 6px', + xs: '1.5px 6px', + s: '1.5px 6px', }, - fontSize: "12px", + fontSize: '12px', id: { - color: "var(--op-wp-meta-color)", + color: 'var(--op-wp-meta-color)', fontWeight: 400, }, type: { fontWeight: 600, - letterSpacing: "0.04em", - textTransform: "uppercase" as const, + letterSpacing: '0.04em', + textTransform: 'uppercase' as const, }, subject: { - color: "var(--bn-colors-highlights-blue-text)", + color: 'var(--bn-colors-highlights-blue-text)', fontWeight: 600, - fontSize: "14px", + fontSize: '14px', }, status: { - bg: "var(--op-status-bg)", - border: "1px solid var(--op-status-border-color)", - padding: "1px 8px", - radius: "100px", - color: "var(--bn-colors-editor-text)", + bg: 'var(--op-status-bg)', + border: '1px solid var(--op-status-border-color)', + padding: '1px 8px', + radius: '100px', + color: 'var(--bn-colors-editor-text)', fontWeight: 600, - gap: "4px", + gap: '4px', chevron: { - color: "var(--bn-colors-highlights-gray-text)", - width: "7.29px", - height: "3.90px", + color: 'var(--bn-colors-highlights-gray-text)', + width: '7.29px', + height: '3.90px', }, }, - focusOutline: "4px solid var(--mantine-color-blue-4)", - focusShadow: "none", + focusOutline: '4px solid var(--mantine-color-blue-4)', + focusShadow: 'none', } as const; \ No newline at end of file diff --git a/lib/components/WorkPackage/types.ts b/lib/components/WorkPackage/types.ts index ee209bc..b4a3222 100644 --- a/lib/components/WorkPackage/types.ts +++ b/lib/components/WorkPackage/types.ts @@ -1,4 +1,4 @@ -export type InlineWpSize = "xxs" | "xs" | "s"; -export type BlockWpSize = "m" | "l" | "xl"; +export type InlineWpSize = 'xxs' | 'xs' | 's'; +export type BlockWpSize = 'm' | 'l' | 'xl'; export type WpSize = InlineWpSize | BlockWpSize; \ No newline at end of file diff --git a/lib/components/index.ts b/lib/components/index.ts index c3312bb..ffdaabc 100644 --- a/lib/components/index.ts +++ b/lib/components/index.ts @@ -1,4 +1,4 @@ -export { openProjectWorkPackageBlockSpec } from "./BlockWorkPackage"; -export { openProjectWorkPackageInlineSpec } from "./InlineWorkPackage"; -export { workPackageSlashMenu } from "./SlashMenu"; -export { ShadowDomWrapper } from "./ShadowDomWrapper"; \ No newline at end of file +export { openProjectWorkPackageBlockSpec } from './BlockWorkPackage'; +export { openProjectWorkPackageInlineSpec } from './InlineWorkPackage'; +export { workPackageSlashMenu } from './SlashMenu'; +export { ShadowDomWrapper } from './ShadowDomWrapper'; \ No newline at end of file diff --git a/lib/hooks/useDeduplicateInstanceIds.ts b/lib/hooks/useDeduplicateInstanceIds.ts index 91e1eab..3c99807 100644 --- a/lib/hooks/useDeduplicateInstanceIds.ts +++ b/lib/hooks/useDeduplicateInstanceIds.ts @@ -1,21 +1,25 @@ -import { useEffect } from "react"; -import type { BlockNoteEditor } from "@blocknote/core"; +import { useEffect } from 'react'; +import type { BlockNoteEditor } from '@blocknote/core'; import { pasteDeduplicatePlugin, pasteDeduplicatePluginKey, -} from "../plugins/pasteDeduplicatePlugin"; +} from '../plugins/pasteDeduplicatePlugin'; export function useDeduplicateInstanceIds( - editor: BlockNoteEditor -): void { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + editor:BlockNoteEditor +):void { useEffect(() => { // accessing private tiptap instance until public API is available + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access const tiptap = (editor as any)._tiptapEditor; if (!tiptap) return; + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access tiptap.registerPlugin(pasteDeduplicatePlugin); return () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access tiptap.unregisterPlugin(pasteDeduplicatePluginKey); }; }, [editor]); diff --git a/lib/hooks/useInlineWpEvents.ts b/lib/hooks/useInlineWpEvents.ts index 1df8e72..ac74d98 100644 --- a/lib/hooks/useInlineWpEvents.ts +++ b/lib/hooks/useInlineWpEvents.ts @@ -4,7 +4,9 @@ import { wpBridge, makeInstanceId } from '../../lib'; import type { InlineWpSize, BlockWpSize, WpSize } from '../../lib'; import { moveCursorAfterBlock } from '../utils/cursor'; +// eslint-disable-next-line @typescript-eslint/no-explicit-any type AnyEditor = BlockNoteEditor; +// eslint-disable-next-line @typescript-eslint/no-explicit-any type AnyInlineNode = InlineContentFromConfig; interface InlineWpNode { @@ -14,25 +16,25 @@ interface InlineWpNode { instanceId:string; size:InlineWpSize; }; - content: AnyInlineNode[]; + content:AnyInlineNode[]; } -const VALID_INLINE_SIZES:Set = new Set(['xxs', 'xs', 's']); +const VALID_INLINE_SIZES = new Set(['xxs', 'xs', 's']); -function isInlineWpNode(node:unknown): node is InlineWpNode { +function isInlineWpNode(node:unknown):node is InlineWpNode { if (typeof node !== 'object' || node === null) return false; const n = node as Record; - if (n['type'] !== 'openProjectWorkPackageInline') return false; + if (n.type !== 'openProjectWorkPackageInline') return false; - const props = n['props']; + const props = n.props; if (typeof props !== 'object' || props === null) return false; const p = props as Record; return ( - typeof p['instanceId'] === 'string' && - typeof p['wpid'] === 'string' && - VALID_INLINE_SIZES.has(p['size'] as InlineWpSize) + typeof p.instanceId === 'string' && + typeof p.wpid === 'string' && + VALID_INLINE_SIZES.has(p.size as InlineWpSize) ); } @@ -47,14 +49,14 @@ interface FoundInlineBlock { } function findInlineChip(editor:AnyEditor, instanceId:string):FoundInlineBlock | null { - let found: FoundInlineBlock | null = null; + let found:FoundInlineBlock | null = null; editor.forEachBlock((block) => { if (found) return false; if (!Array.isArray(block.content)) return true; - const content = (block.content ?? []) as AnyInlineNode[]; + const content = (block.content ?? []); const chip = content.find( (node) => isInlineWpNode(node) && node.props.instanceId === instanceId ) as InlineWpNode | undefined; @@ -76,7 +78,7 @@ function updateInlineChip( editor:AnyEditor, instanceId:string, updater:(chip:InlineWpNode) => InlineWpNode | null -): FoundInlineBlock | null { +):FoundInlineBlock | null { const found = findInlineChip(editor, instanceId); if (!found) return null; @@ -179,7 +181,7 @@ function handleConvertToInline( } // editor instance is stable for the lifetime of the component re-subscription only on editor replacement -export function useInlineWpEvents(editor: AnyEditor):void { +export function useInlineWpEvents(editor:AnyEditor):void { useEffect(() => { const offResize = wpBridge.onResize(({ instanceId, size }) => handleResize(editor, instanceId, size) diff --git a/lib/hooks/useIsNodeInSelection.ts b/lib/hooks/useIsNodeInSelection.ts index bc7de0d..2aa5173 100644 --- a/lib/hooks/useIsNodeInSelection.ts +++ b/lib/hooks/useIsNodeInSelection.ts @@ -1,16 +1,16 @@ -import { useEffect, useState } from "react"; -import type { RefObject } from "react"; -import { isNodeInSelection } from "../utils/selection"; +import { useEffect, useState } from 'react'; +import type { RefObject } from 'react'; +import { isNodeInSelection } from '../utils/selection'; interface SelectionSource { - onSelectionChange: (callback: () => void) => () => void; + onSelectionChange:(callback:() => void) => () => void; } // Tracks whether `nodeRef` is part of the current editor selection. Shadow-DOM safe. export function useIsNodeInSelection( - nodeRef: RefObject, - editor: SelectionSource | undefined, -): boolean { + nodeRef:RefObject, + editor:SelectionSource | undefined, +):boolean { const [inSelection, setInSelection] = useState(false); useEffect(() => { diff --git a/lib/hooks/useOpBlockNoteExtensions.ts b/lib/hooks/useOpBlockNoteExtensions.ts index e2d6837..a1052ae 100644 --- a/lib/hooks/useOpBlockNoteExtensions.ts +++ b/lib/hooks/useOpBlockNoteExtensions.ts @@ -3,8 +3,9 @@ import { useInlineWpEvents } from './useInlineWpEvents'; import { useDeduplicateInstanceIds } from './useDeduplicateInstanceIds'; export function useOpBlockNoteExtensions( - editor: BlockNoteEditor -): void { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + editor:BlockNoteEditor +):void { useInlineWpEvents(editor); useDeduplicateInstanceIds(editor); } \ No newline at end of file diff --git a/lib/hooks/useWorkPackage.ts b/lib/hooks/useWorkPackage.ts index 354cf0c..b74d456 100644 --- a/lib/hooks/useWorkPackage.ts +++ b/lib/hooks/useWorkPackage.ts @@ -1,8 +1,8 @@ -import { useEffect, useState, useCallback } from "react"; -import type { WorkPackage } from "../openProjectTypes"; -import { OpenProjectApiError, fetchWorkPackage } from "../services/openProjectApi"; +import { useEffect, useState, useCallback } from 'react'; +import type { WorkPackage } from '../openProjectTypes'; +import { OpenProjectApiError, fetchWorkPackage } from '../services/openProjectApi'; -export function useWorkPackage(wpid: number|undefined) { +export function useWorkPackage(wpid:number|undefined) { const [workPackage, setWorkPackage] = useState(null); const [loading, setLoading] = useState(false); const [unauthorized, setUnauthorized] = useState(false); @@ -17,7 +17,7 @@ export function useWorkPackage(wpid: number|undefined) { setError(null); try { const data = await fetchWorkPackage(wpid); - setWorkPackage(data as WorkPackage); + setWorkPackage(data); } catch (error) { if (error instanceof OpenProjectApiError && error.responseStatus === 404) { setUnauthorized(true); @@ -32,7 +32,7 @@ export function useWorkPackage(wpid: number|undefined) { }, [wpid]); useEffect(() => { - getWorkPackage(); + void getWorkPackage(); }, [getWorkPackage]); return { workPackage, loading, unauthorized, error }; diff --git a/lib/hooks/useWorkPackageSearch.ts b/lib/hooks/useWorkPackageSearch.ts index bc53d60..0f72029 100644 --- a/lib/hooks/useWorkPackageSearch.ts +++ b/lib/hooks/useWorkPackageSearch.ts @@ -1,17 +1,17 @@ -import { useState, useEffect, useCallback, useRef } from "react"; -import type { WorkPackage } from "../openProjectTypes"; -import { searchWorkPackages } from "../services/openProjectApi"; +import { useState, useEffect, useCallback, useRef } from 'react'; +import type { WorkPackage } from '../openProjectTypes'; +import { searchWorkPackages } from '../services/openProjectApi'; interface UseWorkPackageSearchOptions { - debounce?: number; + debounce?:number; } export function useWorkPackageSearch( - options: UseWorkPackageSearchOptions = {} + options:UseWorkPackageSearchOptions = {} ) { const { debounce = 300 } = options; - const [searchQuery, setSearchQuery] = useState(""); + const [searchQuery, setSearchQuery] = useState(''); const [searchResults, setSearchResults] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -24,6 +24,7 @@ export function useWorkPackageSearch( let active = true; if (!searchQuery) { + // eslint-disable-next-line react-hooks/set-state-in-effect setSearchResults([]); setLoading(false); setError(null); @@ -39,10 +40,10 @@ export function useWorkPackageSearch( setSearchResults(results); } }) - .catch((error) => { + .catch((error:unknown) => { if (active) { - setError(error.message || "Unknown error"); - console.error("[work package search] Failed to load work packages from OpenProject:", error); + setError(error instanceof Error ? error.message : 'Unknown error'); + console.error('[work package search] Failed to load work packages from OpenProject:', error); setSearchResults([]); } }) @@ -61,7 +62,7 @@ export function useWorkPackageSearch( // Imperative search (used by BlockNote getItems — must return results immediately) const search = useCallback( - (query: string): Promise => { + (query:string):Promise => { if (!query.trim()) { setSearchResults([]); return Promise.resolve([]); @@ -72,6 +73,7 @@ export function useWorkPackageSearch( } return new Promise((resolve) => { + // eslint-disable-next-line @typescript-eslint/no-misused-promises debounceTimerRef.current = setTimeout(async () => { debounceTimerRef.current = null; try { diff --git a/lib/hooks/useWorkPackageSearchDropdown.ts b/lib/hooks/useWorkPackageSearchDropdown.ts index ef5b1e7..026ebb3 100644 --- a/lib/hooks/useWorkPackageSearchDropdown.ts +++ b/lib/hooks/useWorkPackageSearchDropdown.ts @@ -1,31 +1,31 @@ -import { useRef, useState } from "react"; -import type { KeyboardEvent, RefObject } from "react"; -import type { WorkPackage } from "../openProjectTypes"; -import { useWorkPackageSearch } from "./useWorkPackageSearch"; +import { useRef, useState } from 'react'; +import type { KeyboardEvent, RefObject } from 'react'; +import type { WorkPackage } from '../openProjectTypes'; +import { useWorkPackageSearch } from './useWorkPackageSearch'; interface UseWorkPackageSearchDropdownOptions { - onSelect: (wp: WorkPackage) => void; - onEscape: () => void; + onSelect:(wp:WorkPackage) => void; + onEscape:() => void; } interface UseWorkPackageSearchDropdownResult { - searchQuery: string; - setSearchQuery: (q: string) => void; - searchResults: WorkPackage[]; - focusedIndex: number; - setFocusedIndex: (i: number) => void; - isDropdownOpen: boolean; - setIsDropdownOpen: (open: boolean) => void; + searchQuery:string; + setSearchQuery:(q:string) => void; + searchResults:WorkPackage[]; + focusedIndex:number; + setFocusedIndex:(i:number) => void; + isDropdownOpen:boolean; + setIsDropdownOpen:(open:boolean) => void; // Exposed so SearchDropdown can set it in onMouseDown before blur fires - isSelectingRef: RefObject; - handleKeyDown: (e: KeyboardEvent) => void; + isSelectingRef:RefObject; + handleKeyDown:(e:KeyboardEvent) => void; } export function useWorkPackageSearchDropdown({ onSelect, onEscape, -}: UseWorkPackageSearchDropdownOptions): UseWorkPackageSearchDropdownResult { +}:UseWorkPackageSearchDropdownOptions):UseWorkPackageSearchDropdownResult { const { searchQuery, setSearchQuery, searchResults } = useWorkPackageSearch(); const [focusedIndex, setFocusedIndex] = useState(-1); const [isDropdownOpen, setIsDropdownOpen] = useState(false); @@ -34,25 +34,25 @@ export function useWorkPackageSearchDropdown({ // it only needs to be readable in the onBlur timeout in SearchDropdown. const isSelectingRef = useRef(false); - const handleKeyDown = (e: KeyboardEvent) => { + const handleKeyDown = (e:KeyboardEvent) => { switch (e.key) { - case "ArrowDown": + case 'ArrowDown': e.preventDefault(); if (!isDropdownOpen) setIsDropdownOpen(true); setFocusedIndex((p) => Math.min(p + 1, searchResults.length - 1)); break; - case "ArrowUp": + case 'ArrowUp': e.preventDefault(); setFocusedIndex((p) => Math.max(p - 1, 0)); break; - case "Enter": + case 'Enter': if (focusedIndex >= 0 && searchResults[focusedIndex]) { e.preventDefault(); isSelectingRef.current = true; onSelect(searchResults[focusedIndex]); } break; - case "Escape": + case 'Escape': onEscape(); break; } diff --git a/lib/index.ts b/lib/index.ts index a16b831..4a72356 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -1,17 +1,17 @@ -import "./services/i18n.ts"; +import './services/i18n.ts'; export { openProjectWorkPackageBlockSpec, openProjectWorkPackageInlineSpec, workPackageSlashMenu, ShadowDomWrapper, -} from "./components"; -export { initializeOpBlockNoteExtensions } from "./initialize"; -export { wpBridge } from "./services/wpBridge.ts"; -export type { WpResizePayload, WpDeletePayload, WpToInlinePayload } from "./services/wpBridge.ts"; -export { makeInstanceId } from "./utils/id.ts"; -export type { InlineWpSize, BlockWpSize, WpSize } from "./components/WorkPackage/types"; -export { createHashWpMenuComponent, isHashWpQuery, useHashWpMenu } from "./components/HashMenu"; -export type { HashMenuItem } from "./components/HashMenu"; -export { useWorkPackageSearch } from "./hooks/useWorkPackageSearch"; -export type { WorkPackage } from "./openProjectTypes"; +} from './components'; +export { initializeOpBlockNoteExtensions } from './initialize'; +export { wpBridge } from './services/wpBridge.ts'; +export type { WpResizePayload, WpDeletePayload, WpToInlinePayload } from './services/wpBridge.ts'; +export { makeInstanceId } from './utils/id.ts'; +export type { InlineWpSize, BlockWpSize, WpSize } from './components/WorkPackage/types'; +export { createHashWpMenuComponent, isHashWpQuery, useHashWpMenu } from './components/HashMenu'; +export type { HashMenuItem } from './components/HashMenu'; +export { useWorkPackageSearch } from './hooks/useWorkPackageSearch'; +export type { WorkPackage } from './openProjectTypes'; export { useOpBlockNoteExtensions } from './hooks/useOpBlockNoteExtensions'; diff --git a/lib/initialize.ts b/lib/initialize.ts index 4fc2586..f643caf 100644 --- a/lib/initialize.ts +++ b/lib/initialize.ts @@ -1,7 +1,7 @@ -import { initOpenProjectApi } from "./services/openProjectApi.ts"; -import { initLanguage } from "./services/i18n.ts"; +import { initOpenProjectApi } from './services/openProjectApi.ts'; +import { initLanguage } from './services/i18n.ts'; -export function initializeOpBlockNoteExtensions(config: { baseUrl: string, locale: string }) { +export function initializeOpBlockNoteExtensions(config:{ baseUrl:string, locale:string }) { initOpenProjectApi({ baseUrl: config.baseUrl }); initLanguage(config.locale); } \ No newline at end of file diff --git a/lib/locales/crowdin/de.ts b/lib/locales/crowdin/de.ts index 3e4a358..d7d5e43 100644 --- a/lib/locales/crowdin/de.ts +++ b/lib/locales/crowdin/de.ts @@ -1,32 +1,32 @@ export const en = { translation: { - "slashMenu": { - "title": "Vorhandenes Arbeitspaket verlinken", - "subtext": "Link zu einem einzelnen Arbeitspaket hinzufügen", - "aliases": { - "workpackage": "Arbeitspaket", - "work package": "Arbeitspaket", - "wp": "ap", - "link": "verlinken" + 'slashMenu': { + 'title': 'Vorhandenes Arbeitspaket verlinken', + 'subtext': 'Link zu einem einzelnen Arbeitspaket hinzufügen', + 'aliases': { + 'workpackage': 'Arbeitspaket', + 'work package': 'Arbeitspaket', + 'wp': 'ap', + 'link': 'verlinken' } }, - "search": { - "label": "Vorhandenes Arbeitspaket verlinken", - "placeholder": "Nach ID oder Betreff suchen", - "dropdownAriaLabel": "Suchergebnisse (Arbeitspakete)" + 'search': { + 'label': 'Vorhandenes Arbeitspaket verlinken', + 'placeholder': 'Nach ID oder Betreff suchen', + 'dropdownAriaLabel': 'Suchergebnisse (Arbeitspakete)' }, - "unavailableWorkPackage": { - "loading": { - "header": "Lädt", - "message": "Bitte warten" + 'unavailableWorkPackage': { + 'loading': { + 'header': 'Lädt', + 'message': 'Bitte warten' }, - "unauthorized": { - "header": "Das verlinkte Arbeitspaket ist nicht verfügbar", - "message": "Aufgrund fehlender Berechtigungen kann es nicht angezeigt werden" + 'unauthorized': { + 'header': 'Das verlinkte Arbeitspaket ist nicht verfügbar', + 'message': 'Aufgrund fehlender Berechtigungen kann es nicht angezeigt werden' }, - "error": { - "header": "Fehler", - "message": "Arbeitspaket konnte nicht geladen werden" + 'error': { + 'header': 'Fehler', + 'message': 'Arbeitspaket konnte nicht geladen werden' } } } diff --git a/lib/locales/crowdin/es.ts b/lib/locales/crowdin/es.ts index e14922e..49e51b9 100644 --- a/lib/locales/crowdin/es.ts +++ b/lib/locales/crowdin/es.ts @@ -1,32 +1,32 @@ export const en = { translation: { - "slashMenu": { - "title": "Vincular el paquete de trabajo existente", - "subtext": "Añadir un enlace dinámico a un solo paquete de trabajo", - "aliases": { - "workpackage": "paquetedetrabajo", - "work package": "paquete de trabajo", - "wp": "pt", - "link": "enlance" + 'slashMenu': { + 'title': 'Vincular el paquete de trabajo existente', + 'subtext': 'Añadir un enlace dinámico a un solo paquete de trabajo', + 'aliases': { + 'workpackage': 'paquetedetrabajo', + 'work package': 'paquete de trabajo', + 'wp': 'pt', + 'link': 'enlance' } }, - "search": { - "label": "Vincular el paquete de trabajo existente", - "placeholder": "Buscar paquete de trabajo por ID o asunto", - "dropdownAriaLabel": "Resultados de la búsqueda de paquetes de trabajo" + 'search': { + 'label': 'Vincular el paquete de trabajo existente', + 'placeholder': 'Buscar paquete de trabajo por ID o asunto', + 'dropdownAriaLabel': 'Resultados de la búsqueda de paquetes de trabajo' }, - "unavailableWorkPackage": { - "loading": { - "header": "Cargando", - "message": "Por favor, espere" + 'unavailableWorkPackage': { + 'loading': { + 'header': 'Cargando', + 'message': 'Por favor, espere' }, - "unauthorized": { - "header": "Paquete de trabajo vinculado no disponible", - "message": "Usted no está autorizado para ver este contenido" + 'unauthorized': { + 'header': 'Paquete de trabajo vinculado no disponible', + 'message': 'Usted no está autorizado para ver este contenido' }, - "error": { - "header": "Error", - "message": "No se ha podido cargar el paquete de trabajo" + 'error': { + 'header': 'Error', + 'message': 'No se ha podido cargar el paquete de trabajo' } } } diff --git a/lib/locales/crowdin/fr.ts b/lib/locales/crowdin/fr.ts index 36c38fb..744d718 100644 --- a/lib/locales/crowdin/fr.ts +++ b/lib/locales/crowdin/fr.ts @@ -1,32 +1,32 @@ export const en = { translation: { - "slashMenu": { - "title": "Lien vers un lot de travaux existant", - "subtext": "Ajouter un lien dynamique à un lot de travaux unique", - "aliases": { - "workpackage": "lotdetravaux", - "work package": "lot de travaux", - "wp": "lt", - "link": "lien" + 'slashMenu': { + 'title': 'Lien vers un lot de travaux existant', + 'subtext': 'Ajouter un lien dynamique à un lot de travaux unique', + 'aliases': { + 'workpackage': 'lotdetravaux', + 'work package': 'lot de travaux', + 'wp': 'lt', + 'link': 'lien' } }, - "search": { - "label": "Lien vers un lot de travaux existant", - "placeholder": "Rechercher l'ID ou le sujet du lot de travaux", - "dropdownAriaLabel": "Résultats de la recherche de lots de travaux" + 'search': { + 'label': 'Lien vers un lot de travaux existant', + 'placeholder': "Rechercher l'ID ou le sujet du lot de travaux", + 'dropdownAriaLabel': 'Résultats de la recherche de lots de travaux' }, - "unavailableWorkPackage": { - "loading": { - "header": "Сhargement", - "message": "Veuillez patienter" + 'unavailableWorkPackage': { + 'loading': { + 'header': 'Сhargement', + 'message': 'Veuillez patienter' }, - "unauthorized": { - "header": "Le lot de travaux lié n'est pas disponible", - "message": "Vous n'avez pas l'autorisation de voir ce contenu" + 'unauthorized': { + 'header': "Le lot de travaux lié n'est pas disponible", + 'message': "Vous n'avez pas l'autorisation de voir ce contenu" }, - "error": { - "header": "Erreur", - "message": "Impossible de charger le lot de travaux" + 'error': { + 'header': 'Erreur', + 'message': 'Impossible de charger le lot de travaux' } } } diff --git a/lib/locales/crowdin/nl.ts b/lib/locales/crowdin/nl.ts index 13e0e42..11a3409 100644 --- a/lib/locales/crowdin/nl.ts +++ b/lib/locales/crowdin/nl.ts @@ -1,32 +1,32 @@ export const en = { translation: { - "slashMenu": { - "title": "Link existing work package", - "subtext": "Add a dynamic link to a single work package", - "aliases": { - "workpackage": "workpackage", - "work package": "work package", - "wp": "wp", - "link": "link" + 'slashMenu': { + 'title': 'Link existing work package', + 'subtext': 'Add a dynamic link to a single work package', + 'aliases': { + 'workpackage': 'workpackage', + 'work package': 'work package', + 'wp': 'wp', + 'link': 'link' } }, - "search": { - "label": "Link existing work package", - "placeholder": "Search by work package ID or subject", - "dropdownAriaLabel": "Work package search results" + 'search': { + 'label': 'Link existing work package', + 'placeholder': 'Search by work package ID or subject', + 'dropdownAriaLabel': 'Work package search results' }, - "unavailableWorkPackage": { - "loading": { - "header": "Loading", - "message": "Please wait" + 'unavailableWorkPackage': { + 'loading': { + 'header': 'Loading', + 'message': 'Please wait' }, - "unauthorized": { - "header": "Linked work package unavailable", - "message": "You do not have permission to see this" + 'unauthorized': { + 'header': 'Linked work package unavailable', + 'message': 'You do not have permission to see this' }, - "error": { - "header": "Error", - "message": "Could not load work package" + 'error': { + 'header': 'Error', + 'message': 'Could not load work package' } } } diff --git a/lib/locales/crowdin/pt.ts b/lib/locales/crowdin/pt.ts index 6882a98..c5bacc5 100644 --- a/lib/locales/crowdin/pt.ts +++ b/lib/locales/crowdin/pt.ts @@ -1,32 +1,32 @@ export const en = { translation: { - "slashMenu": { - "title": "Ligue o pacote de trabalho existente", - "subtext": "Adicione uma ligação dinâmica a um único pacote de trabalho", - "aliases": { - "workpackage": "pacote de trabalho", - "work package": "pacote de trabalho", - "wp": "wp", - "link": "link" + 'slashMenu': { + 'title': 'Ligue o pacote de trabalho existente', + 'subtext': 'Adicione uma ligação dinâmica a um único pacote de trabalho', + 'aliases': { + 'workpackage': 'pacote de trabalho', + 'work package': 'pacote de trabalho', + 'wp': 'wp', + 'link': 'link' } }, - "search": { - "label": "Ligue o pacote de trabalho existente", - "placeholder": "Pesquise por ID do pacote de trabalho ou assunto", - "dropdownAriaLabel": "Resultados da pesquisa de pacotes de trabalho" + 'search': { + 'label': 'Ligue o pacote de trabalho existente', + 'placeholder': 'Pesquise por ID do pacote de trabalho ou assunto', + 'dropdownAriaLabel': 'Resultados da pesquisa de pacotes de trabalho' }, - "unavailableWorkPackage": { - "loading": { - "header": "A carregar", - "message": "Aguarde, por favor" + 'unavailableWorkPackage': { + 'loading': { + 'header': 'A carregar', + 'message': 'Aguarde, por favor' }, - "unauthorized": { - "header": "Pacote de trabalho associado não disponível", - "message": "Não tem permissão para ver isto" + 'unauthorized': { + 'header': 'Pacote de trabalho associado não disponível', + 'message': 'Não tem permissão para ver isto' }, - "error": { - "header": "Erro", - "message": "Não foi possível carregar o pacote de trabalho" + 'error': { + 'header': 'Erro', + 'message': 'Não foi possível carregar o pacote de trabalho' } } } diff --git a/lib/locales/crowdin/uk.ts b/lib/locales/crowdin/uk.ts index 8332803..e225e7b 100644 --- a/lib/locales/crowdin/uk.ts +++ b/lib/locales/crowdin/uk.ts @@ -1,32 +1,32 @@ export const en = { translation: { - "slashMenu": { - "title": "Link existing work package", - "subtext": "Add a dynamic link to a single work package", - "aliases": { - "workpackage": "робочий пакет", - "work package": "пакет робіт", - "wp": "пр", - "link": "посилання" + 'slashMenu': { + 'title': 'Link existing work package', + 'subtext': 'Add a dynamic link to a single work package', + 'aliases': { + 'workpackage': 'робочий пакет', + 'work package': 'пакет робіт', + 'wp': 'пр', + 'link': 'посилання' } }, - "search": { - "label": "Додати наявні пакети робіт", - "placeholder": "Пошук за ID робочого пакета або темою", - "dropdownAriaLabel": "Результати пошуку робочого пакета" + 'search': { + 'label': 'Додати наявні пакети робіт', + 'placeholder': 'Пошук за ID робочого пакета або темою', + 'dropdownAriaLabel': 'Результати пошуку робочого пакета' }, - "unavailableWorkPackage": { - "loading": { - "header": "Завантаження…", - "message": "Будь ласка, зачекайте" + 'unavailableWorkPackage': { + 'loading': { + 'header': 'Завантаження…', + 'message': 'Будь ласка, зачекайте' }, - "unauthorized": { - "header": "Пов'язаний робочий пакет недоступний", - "message": "У вас немає прав щоб побачити це." + 'unauthorized': { + 'header': "Пов'язаний робочий пакет недоступний", + 'message': 'У вас немає прав щоб побачити це.' }, - "error": { - "header": "Помилка", - "message": "Не вдалося завантажити робочий пакет" + 'error': { + 'header': 'Помилка', + 'message': 'Не вдалося завантажити робочий пакет' } } } diff --git a/lib/locales/en.ts b/lib/locales/en.ts index 9ecfec1..c72c9a0 100644 --- a/lib/locales/en.ts +++ b/lib/locales/en.ts @@ -1,56 +1,56 @@ export const en = { translation: { - "slashMenu": { - "title": "Link existing work package", - "subtext": "Add a dynamic link to a single work package", - "aliases": { - "workpackage": "workpackage", - "work package": "work package", - "wp": "wp", - "link": "link" + 'slashMenu': { + 'title': 'Link existing work package', + 'subtext': 'Add a dynamic link to a single work package', + 'aliases': { + 'workpackage': 'workpackage', + 'work package': 'work package', + 'wp': 'wp', + 'link': 'link' } }, - "search": { - "label": "Link existing work package", - "placeholder": "Search by work package ID or subject", - "dropdownAriaLabel": "Work package search results", + 'search': { + 'label': 'Link existing work package', + 'placeholder': 'Search by work package ID or subject', + 'dropdownAriaLabel': 'Work package search results', }, - "unavailableWorkPackage": { - "loading": { - "header": "Loading", - "message": "Please wait" + 'unavailableWorkPackage': { + 'loading': { + 'header': 'Loading', + 'message': 'Please wait' }, - "unauthorized": { - "header": "Linked work package unavailable", - "message": "You do not have permission to see this" + 'unauthorized': { + 'header': 'Linked work package unavailable', + 'message': 'You do not have permission to see this' }, - "error": { - "header": "Error", - "message": "Could not load work package" + 'error': { + 'header': 'Error', + 'message': 'Could not load work package' } }, - "options": { - "openInNewTab": "Open in new tab", - "open": "Open", - "changeSize": "Change size", - "remove": "Remove", - "removeAriaLabel": "Remove work package", - "openAriaLabel": "Open work package {{id}} in new tab", - "inlineSizeLabel": "Inline size", - "blockSizeLabel": "Block size", - "chipAriaLabel": "Work package {{id}}", + 'options': { + 'openInNewTab': 'Open in new tab', + 'open': 'Open', + 'changeSize': 'Change size', + 'remove': 'Remove', + 'removeAriaLabel': 'Remove work package', + 'openAriaLabel': 'Open work package {{id}} in new tab', + 'inlineSizeLabel': 'Inline size', + 'blockSizeLabel': 'Block size', + 'chipAriaLabel': 'Work package {{id}}', }, - "sizes": { - "xxs": { "label": "Tiny", "desc": "Identifier" }, - "xs": { "label": "Compact", "desc": "Type, Identifier, Subject" }, - "s": { "label": "Regular", "desc": "Status, Type, Identifier, Subject" }, - "m": { "label": "Compact card", "desc": "Status, Type, Identifier, Subject" }, - "l": { "label": "Regular card", "desc": "Identifier, Subject, Type, Status, Parent, Project" }, - "xl": { "label": "Full card", "desc": "Identifier, Subject, Type, Status, Parent, Project, Description" } + 'sizes': { + 'xxs': { 'label': 'Tiny', 'desc': 'Identifier' }, + 'xs': { 'label': 'Compact', 'desc': 'Type, Identifier, Subject' }, + 's': { 'label': 'Regular', 'desc': 'Status, Type, Identifier, Subject' }, + 'm': { 'label': 'Compact card', 'desc': 'Status, Type, Identifier, Subject' }, + 'l': { 'label': 'Regular card', 'desc': 'Identifier, Subject, Type, Status, Parent, Project' }, + 'xl': { 'label': 'Full card', 'desc': 'Identifier, Subject, Type, Status, Parent, Project, Description' } }, - "hashMenu": { - "typeToSearch": "Type to search work packages…", - "noResults": "No results for \"{{query}}\"" + 'hashMenu': { + 'typeToSearch': 'Type to search work packages…', + 'noResults': 'No results for "{{query}}"' }, } }; \ No newline at end of file diff --git a/lib/openProjectTypes.ts b/lib/openProjectTypes.ts index 4fd4a55..8798f35 100644 --- a/lib/openProjectTypes.ts +++ b/lib/openProjectTypes.ts @@ -1,63 +1,63 @@ export interface WorkPackage { - id: number; - displayId: string; - subject: string; - description?: { raw?: string; html?: string } | null; - status?: string | null; - assignee?: string | null; - href?: string | null; - lockVersion?: number | null; - _links?: { - self: { href: string }; - status: { title: string; href: string } | null; - assignee: { title: string; href: string } | null; - type: { title: string; href: string } | null; - parent?: { title: string; href: string } | null; - project?: { title: string; href: string } | null; + id:number; + displayId:string; + subject:string; + description?:{ raw?:string; html?:string } | null; + status?:string | null; + assignee?:string | null; + href?:string | null; + lockVersion?:number | null; + _links?:{ + self:{ href:string }; + status:{ title:string; href:string } | null; + assignee:{ title:string; href:string } | null; + type:{ title:string; href:string } | null; + parent?:{ title:string; href:string } | null; + project?:{ title:string; href:string } | null; } | null; } export interface WorkPackageCollection { - _embedded: { - elements: WorkPackage[]; + _embedded:{ + elements:WorkPackage[]; }; } export interface StatusCollection { - _embedded?: { - elements?: Array<{ - id: string; - name: string; - isClosed: boolean; - color: string; - _links: { - self: { href: string }; + _embedded?:{ + elements?:{ + id:string; + name:string; + isClosed:boolean; + color:string; + _links:{ + self:{ href:string }; }; - }>; + }[]; }; } export interface TypeCollection { - _embedded?: { - elements?: Array<{ - id: string; - name: string; - color: string; - _links: { - self: { href: string }; + _embedded?:{ + elements?:{ + id:string; + name:string; + color:string; + _links:{ + self:{ href:string }; }; - }>; + }[]; }; } export interface OpenProjectResponse { - _embedded?: { - elements?: Array<{ - id: string; - name: string; - _links?: { self: { href: string } }; - }>; + _embedded?:{ + elements?:{ + id:string; + name:string; + _links?:{ self:{ href:string } }; + }[]; }; } -export type OpColorMode = "light" | "dark"; \ No newline at end of file +export type OpColorMode = 'light' | 'dark'; \ No newline at end of file diff --git a/lib/plugins/pasteDeduplicatePlugin.ts b/lib/plugins/pasteDeduplicatePlugin.ts index fa1de96..9fc6801 100644 --- a/lib/plugins/pasteDeduplicatePlugin.ts +++ b/lib/plugins/pasteDeduplicatePlugin.ts @@ -1,7 +1,7 @@ -import { Plugin, PluginKey } from "prosemirror-state"; -import { Fragment, Slice } from "prosemirror-model"; -import type { Node } from "prosemirror-model"; -import { makeInstanceId } from "../utils/id"; +import { Plugin, PluginKey } from 'prosemirror-state'; +import { Fragment, Slice } from 'prosemirror-model'; +import type { Node } from 'prosemirror-model'; +import { makeInstanceId } from '../utils/id'; /** * Regenerates instanceId for every inline WP chip in pasted content. @@ -11,7 +11,7 @@ import { makeInstanceId } from "../utils/id"; * to always affect the first chip instead of the intended one. */ export const pasteDeduplicatePluginKey = new PluginKey( - "pasteDeduplicateInstanceIds" + 'pasteDeduplicateInstanceIds' ); export const pasteDeduplicatePlugin = new Plugin({ @@ -28,12 +28,12 @@ export const pasteDeduplicatePlugin = new Plugin({ }, }); -function transformFragment(fragment: Fragment): Fragment { - const nodes: Node[] = []; +function transformFragment(fragment:Fragment):Fragment { + const nodes:Node[] = []; fragment.forEach((node) => { if ( - node.type.name === "openProjectWorkPackageInline" && + node.type.name === 'openProjectWorkPackageInline' && node.attrs.instanceId ) { nodes.push( diff --git a/lib/services/colors.ts b/lib/services/colors.ts index acbbc2b..0ce227f 100644 --- a/lib/services/colors.ts +++ b/lib/services/colors.ts @@ -1,13 +1,13 @@ -import { type WorkPackage, type OpColorMode } from "../openProjectTypes"; -import { fetchTypes, fetchStatuses } from "./openProjectApi"; -import { useEffect, useState } from "react"; +import { type WorkPackage, type OpColorMode } from '../openProjectTypes'; +import { fetchTypes, fetchStatuses } from './openProjectApi'; +import { useEffect, useState } from 'react'; -const FALLBACK_TYPE_COLOR = "#3f3f3f"; -const FALLBACK_STATUS_COLOR = "#D2DAE4"; +const FALLBACK_TYPE_COLOR = '#3f3f3f'; +const FALLBACK_STATUS_COLOR = '#D2DAE4'; const statusColors:Record = {}; const typeColors:Record = {}; -let colorsPromise: Promise | null = null; +let colorsPromise:Promise | null = null; // Load colors only once (called when OpenProjectWorkPackageBlock is initialized). // And ensure that the component is re-rendered after colors are loaded. @@ -20,7 +20,7 @@ export function useColors() { if (isLoaded) return; let active = true; - cacheColors().then(() => { + void cacheColors().then(() => { if (active) { setIsLoaded(true); } @@ -34,7 +34,7 @@ export function useColors() { return isLoaded; } -export function cacheColors(): Promise { +export function cacheColors():Promise { if (colorsPromise) { return colorsPromise; } @@ -58,46 +58,46 @@ export function cacheColors(): Promise { } }); })(), - ]).then(() => {}).catch((error) => { - console.error("[colors] Failed to load colors from OpenProject:", error); + ]).then(() => undefined).catch((error) => { + console.error('[colors] Failed to load colors from OpenProject:', error); }); return colorsPromise; } -export function typeColor(workPackage: WorkPackage) { - if (!workPackage._links || !workPackage._links.type) { +export function typeColor(workPackage:WorkPackage) { + if (!workPackage._links?.type) { return FALLBACK_TYPE_COLOR; } - const typeId = idFromHref(workPackage._links.type.href) ?? ""; + const typeId = idFromHref(workPackage._links.type.href) ?? ''; return typeColors[typeId] || FALLBACK_TYPE_COLOR; } -export function statusColor(workPackage: WorkPackage) { - if (!workPackage._links || !workPackage._links.status) { +export function statusColor(workPackage:WorkPackage) { + if (!workPackage._links?.status) { return FALLBACK_STATUS_COLOR; } - const statusId = idFromHref(workPackage._links.status.href) ?? ""; + const statusId = idFromHref(workPackage._links.status.href) ?? ''; return statusColors[statusId] || FALLBACK_STATUS_COLOR; } -export function defaultColorStyles(hexColor: string) { +export function defaultColorStyles(hexColor:string) { const hsl = hexToHSL(hexColor); return ` - --color-r: ${parseHexColorValue(hexColor, "r")}; - --color-g: ${parseHexColorValue(hexColor, "g")}; - --color-b: ${parseHexColorValue(hexColor, "b")}; + --color-r: ${parseHexColorValue(hexColor, 'r')}; + --color-g: ${parseHexColorValue(hexColor, 'g')}; + --color-b: ${parseHexColorValue(hexColor, 'b')}; --color-h: ${hsl.h}; --color-s: ${hsl.s}; --color-l: ${hsl.l}; --perceived-lightness: calc( ((var(--color-r) * 0.2126) + (var(--color-g) * 0.7152) + (var(--color-b) * 0.0722)) / 255 ); --lightness-switch: max(0, min(calc((1/(var(--lightness-threshold) - var(--perceived-lightness)))), 1)); --lighten-by: calc(((var(--lightness-threshold) - var(--perceived-lightness)) * 100) * var(--lightness-switch)); - ` + `; } export function defaultVariables() { - if (getTheme() === "dark") { + if (getTheme() === 'dark') { return ` --lightness-threshold: 0.6; --background-alpha: 0.10; // this is darker than the default of OpenProject, but BlockNotes dark mode backgrounds are lighter @@ -106,55 +106,55 @@ export function defaultVariables() { return ` --lightness-threshold: 0.453; - ` + `; } export function statusBorderColor() { - if (getTheme() === "dark") { + if (getTheme() === 'dark') { return wantsHighContrast() - ? `hsl(var(--color-h), calc(var(--color-s) * 1%), calc((var(--color-l) + 10 + var(--lighten-by)) * 1%))` - : `hsl(var(--color-h), calc(var(--color-s) * 1%), calc((var(--color-l) + var(--lighten-by)) * 1%))`; + ? 'hsl(var(--color-h), calc(var(--color-s) * 1%), calc((var(--color-l) + 10 + var(--lighten-by)) * 1%))' + : 'hsl(var(--color-h), calc(var(--color-s) * 1%), calc((var(--color-l) + var(--lighten-by)) * 1%))'; } // light theme return wantsHighContrast() - ? `hsla(var(--color-h), calc(var(--color-s) * 1%), calc((var(--color-l) - 75) * 1%), 1)` - : `hsl(var(--color-h), calc(var(--color-s) * 1%), calc((var(--color-l) - 15) * 1%))`; + ? 'hsla(var(--color-h), calc(var(--color-s) * 1%), calc((var(--color-l) - 75) * 1%), 1)' + : 'hsl(var(--color-h), calc(var(--color-s) * 1%), calc((var(--color-l) - 15) * 1%))'; } export function statusBackgroundColor() { - if (getTheme() === "dark") return `rgba(var(--color-r), var(--color-g), var(--color-b), var(--background-alpha))`; + if (getTheme() === 'dark') return 'rgba(var(--color-r), var(--color-g), var(--color-b), var(--background-alpha))'; // light theme - return `rgb(var(--color-r), var(--color-g), var(--color-b))`; + return 'rgb(var(--color-r), var(--color-g), var(--color-b))'; } export function statusTextColor() { - if (getTheme() === "dark") return `hsl(var(--color-h), calc(var(--color-s) * 1%), calc((var(--color-l) + var(--lighten-by)) * 1%))`; + if (getTheme() === 'dark') return 'hsl(var(--color-h), calc(var(--color-s) * 1%), calc((var(--color-l) + var(--lighten-by)) * 1%))'; //light theme - return `hsl(0deg, 0%, calc(var(--lightness-switch) * 100%))` + return 'hsl(0deg, 0%, calc(var(--lightness-switch) * 100%))'; } export function typeTextColor() { - if (getTheme() === "dark") { + if (getTheme() === 'dark') { return wantsHighContrast() - ? `hsla(var(--color-h), calc(var(--color-s) * 1%), calc((var(--color-l) + 10 + var(--lighten-by)) * 1%), 1)` - : `hsla(var(--color-h), calc(var(--color-s) * 1%), calc((var(--color-l) + var(--lighten-by)) * 1%), 1)`; + ? 'hsla(var(--color-h), calc(var(--color-s) * 1%), calc((var(--color-l) + 10 + var(--lighten-by)) * 1%), 1)' + : 'hsla(var(--color-h), calc(var(--color-s) * 1%), calc((var(--color-l) + var(--lighten-by)) * 1%), 1)'; } // light theme return wantsHighContrast() - ? `hsla(var(--color-h), calc(var(--color-s) * 1%), calc((var(--color-l) - (var(--color-l) * 0.5)) * 1%), 1)` - : `hsla(var(--color-h), calc(var(--color-s) * 1%), calc((var(--color-l) - (var(--color-l) * 0.22)) * 1%), 1)`; + ? 'hsla(var(--color-h), calc(var(--color-s) * 1%), calc((var(--color-l) - (var(--color-l) * 0.5)) * 1%), 1)' + : 'hsla(var(--color-h), calc(var(--color-s) * 1%), calc((var(--color-l) - (var(--color-l) * 0.22)) * 1%), 1)'; } -function hexToHSL(hexColor: string): { h: number; s: number; l: number } { +function hexToHSL(hexColor:string):{ h:number; s:number; l:number } { const color = cleanColorString(hexColor); - const r = parseHexColorValue(color, "r") / 255; - const g = parseHexColorValue(color, "g") / 255; - const b = parseHexColorValue(color, "b") / 255; + const r = parseHexColorValue(color, 'r') / 255; + const g = parseHexColorValue(color, 'g') / 255; + const b = parseHexColorValue(color, 'b') / 255; const max = Math.max(r, g, b); const min = Math.min(r, g, b); @@ -183,52 +183,52 @@ function hexToHSL(hexColor: string): { h: number; s: number; l: number } { return {h: Math.round(h * 360), s: Math.round(s * 100), l: Math.round(l * 100)}; } -function parseHexColorValue(colorString: string, channel: "r" | "g" | "b"): number { +function parseHexColorValue(colorString:string, channel:'r' | 'g' | 'b'):number { const color = cleanColorString(colorString); let colorSubString; switch (channel) { - case "r": colorSubString = color.substring(0, 2); break; - case "g": colorSubString = color.substring(2, 4); break; - case "b": colorSubString = color.substring(4, 6); break; + case 'r': colorSubString = color.substring(0, 2); break; + case 'g': colorSubString = color.substring(2, 4); break; + case 'b': colorSubString = color.substring(4, 6); break; } return parseInt(colorSubString, 16); } -function cleanColorString(colorString: string) { +function cleanColorString(colorString:string) { return colorString - .replace("#", "") - .padEnd(6, "0"); + .replace('#', '') + .padEnd(6, '0'); } -function idFromHref(href: string) { - return href.split("/").pop(); +function idFromHref(href:string) { + return href.split('/').pop(); } -let theme: OpColorMode; -function getTheme(): OpColorMode { +let theme:OpColorMode; +function getTheme():OpColorMode { return theme ?? (theme = detectTheme()); } -function detectTheme(): OpColorMode { +function detectTheme():OpColorMode { const detected = document.querySelector('.bn-container')?.getAttribute('data-color-scheme'); - if (detected === "light" || detected === "dark") { + if (detected === 'light' || detected === 'dark') { return detected; } - return "light"; + return 'light'; } -let highContrast: boolean; -function wantsHighContrast(): boolean { +let highContrast:boolean; +function wantsHighContrast():boolean { return highContrast ?? (highContrast = detectHighContrast()); } -function detectHighContrast(): boolean { +function detectHighContrast():boolean { const osContrast = window.matchMedia('(prefers-contrast: more)'); const opContrast = document.querySelector('body')?.getAttribute('data-auto-theme-switcher-increase-contrast-value'); const opForceLightContrast = document.querySelector('body')?.getAttribute('data-auto-theme-switcher-force-light-contrast-value'); const opForceDarkContrast = document.querySelector('body')?.getAttribute('data-auto-theme-switcher-force-dark-contrast-value'); - return ((osContrast.matches && !(opContrast === "false")) || opContrast === "true" || (getTheme() == "light" && opForceLightContrast === "true") || (getTheme() == "dark" && opForceDarkContrast === "true")); + return ((osContrast.matches && !(opContrast === 'false')) || opContrast === 'true' || (getTheme() == 'light' && opForceLightContrast === 'true') || (getTheme() == 'dark' && opForceDarkContrast === 'true')); } diff --git a/lib/services/i18n.ts b/lib/services/i18n.ts index 55c95fb..a461f0a 100644 --- a/lib/services/i18n.ts +++ b/lib/services/i18n.ts @@ -1,33 +1,36 @@ -import i18n from "i18next"; -import { initReactI18next } from "react-i18next"; -import { en } from "../locales/en.ts"; +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; +import { en } from '../locales/en.ts'; -export function initLanguage(locale: string) { - i18n.changeLanguage(locale); +export function initLanguage(locale:string) { + void i18n.changeLanguage(locale); } -const resources: Record = { +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const resources:Record = { en, }; -const localeModules = import.meta.glob("../locales/crowdin/*.ts", { eager: true }); +const localeModules = import.meta.glob('../locales/crowdin/*.ts', { eager: true }); for (const path in localeModules) { - const locale = path.match(/([^/]+)\.ts$/)?.[1]; + const locale = (/([^/]+)\.ts$/.exec(path))?.[1]; if (locale) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment const mod = localeModules[path] as any; + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/prefer-nullish-coalescing, @typescript-eslint/no-unsafe-argument resources[locale] = mod[locale] || mod.default || Object.values(mod)[0]; } } if (!i18n.isInitialized) { - i18n + void i18n .use(initReactI18next) .init({ resources, - lng: "en", - fallbackLng: "en", + lng: 'en', + fallbackLng: 'en', }); } -export default i18n; \ No newline at end of file +export default i18n; diff --git a/lib/services/openProjectApi.ts b/lib/services/openProjectApi.ts index e2c32f6..08561d0 100644 --- a/lib/services/openProjectApi.ts +++ b/lib/services/openProjectApi.ts @@ -1,65 +1,67 @@ -import type {OpenProjectResponse, StatusCollection, TypeCollection, WorkPackage} from "../openProjectTypes"; +import type {OpenProjectResponse, StatusCollection, TypeCollection, WorkPackage} from '../openProjectTypes'; -let baseUrl = "https://openproject.local"; +let baseUrl = 'https://openproject.local'; export class OpenProjectApiError extends Error { - responseStatus?: number; + responseStatus?:number; - constructor(message: string, responseStatus?: number) { + constructor(message:string, responseStatus?:number) { super(message); this.responseStatus = responseStatus; - this.name = "OpenProjectApiError"; + this.name = 'OpenProjectApiError'; } } -export function initOpenProjectApi(config: { baseUrl: string }) { +export function initOpenProjectApi(config:{ baseUrl:string }) { baseUrl = config.baseUrl; if (baseUrl.endsWith('/')) { baseUrl = baseUrl.slice(0, -1); } } -async function get(endpoint: string): Promise { +async function get(endpoint:string):Promise { const response = await fetch(`${baseUrl}${endpoint}`, { - method: "GET", - headers: { "Content-Type": "application/json" }, + method: 'GET', + headers: { 'Content-Type': 'application/json' }, }); if (!response.ok) { throw new OpenProjectApiError(`HTTP error! status: ${response.status} - ${response.statusText}`, response.status); } - return response.json(); + return response.json() as Promise; } -export function linkToWorkPackage(displayId: string): string { +export function linkToWorkPackage(displayId:string):string { return `${baseUrl}/wp/${encodeURIComponent(displayId)}`; } -export function fetchWorkPackage(id: number): Promise { +export function fetchWorkPackage(id:number):Promise { if (isNaN(id) || id <= 0) { return Promise.reject(new OpenProjectApiError(`Invalid work package ID: ${id}`)); } return get(`/api/v3/work_packages/${id}`); } -export function fetchStatuses(): Promise { - return get(`/api/v3/statuses`).catch((error) => { - console.error("[OpenProjectApi] fetchStatuses failed:", error); +export function fetchStatuses():Promise { + return get('/api/v3/statuses').catch((error:unknown) => { + console.error('[OpenProjectApi] fetchStatuses failed:', error); + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors return Promise.reject(error); }); } -export function fetchTypes(): Promise { - return get(`/api/v3/types`).catch((error) => { - console.error("[OpenProjectApi] fetchTypes failed:", error); +export function fetchTypes():Promise { + return get('/api/v3/types').catch((error:unknown) => { + console.error('[OpenProjectApi] fetchTypes failed:', error); + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors return Promise.reject(error); }); } -export async function searchWorkPackages(query: string): Promise { +export async function searchWorkPackages(query:string):Promise { const filters = encodeURIComponent(`[{"typeahead":{"operator":"**","values":["${query}"]}}]`); - const sortBy = encodeURIComponent(`[["updatedAt","desc"]]`); + const sortBy = encodeURIComponent('[["updatedAt","desc"]]'); const endpoint = `/api/v3/work_packages?filters=${filters}&sortBy=${sortBy}`; const data = await get(endpoint); diff --git a/lib/services/slashMenuAliases.ts b/lib/services/slashMenuAliases.ts index 73b60c3..1a3e3ce 100644 --- a/lib/services/slashMenuAliases.ts +++ b/lib/services/slashMenuAliases.ts @@ -1,8 +1,8 @@ -import i18n from "../services/i18n.ts"; +import i18n from '../services/i18n.ts'; -let aliases: Array | undefined; +let aliases:string[] | undefined; -export function getAliases(): string[] { +export function getAliases():string[] { return aliases ?? (aliases = calculateAliases()); } @@ -10,8 +10,8 @@ i18n.on('languageChanged', () => { aliases = undefined; }); -function calculateAliases(): string[] { - const combinations: string[] = []; +function calculateAliases():string[] { + const combinations:string[] = []; for (const namespace of namespaces()) { for (const objectType of objectTypes()) { @@ -30,25 +30,25 @@ function calculateAliases(): string[] { } function namespaces() { - return ["openproject", "op"]; + return ['openproject', 'op']; } function objectTypes() { const types = new Set(); - types.add("wp"); - types.add("work package"); - types.add("workpackage"); - types.add(i18n.t("slashMenu.aliases.workpackage")); - types.add(i18n.t("slashMenu.aliases.work package")); - types.add(i18n.t("slashMenu.aliases.wp")); + types.add('wp'); + types.add('work package'); + types.add('workpackage'); + types.add(i18n.t('slashMenu.aliases.workpackage')); + types.add(i18n.t('slashMenu.aliases.work package')); + types.add(i18n.t('slashMenu.aliases.wp')); return types; } function functionNames() { const names = new Set; - names.add("link"); - names.add(i18n.t("slashMenu.aliases.link")); + names.add('link'); + names.add(i18n.t('slashMenu.aliases.link')); return names; } \ No newline at end of file diff --git a/lib/services/wpBridge.ts b/lib/services/wpBridge.ts index 56ff773..897e857 100644 --- a/lib/services/wpBridge.ts +++ b/lib/services/wpBridge.ts @@ -1,7 +1,7 @@ -import type { InlineWpSize, WpSize } from "../components/WorkPackage/types"; -export interface WpResizePayload { instanceId: string; wpid: number; size: WpSize } -export interface WpDeletePayload { instanceId: string; wpid: number } -export interface WpToInlinePayload { wpid: number; size: InlineWpSize; blockId: string } +import type { InlineWpSize, WpSize } from '../components/WorkPackage/types'; +export interface WpResizePayload { instanceId:string; wpid:number; size:WpSize } +export interface WpDeletePayload { instanceId:string; wpid:number } +export interface WpToInlinePayload { wpid:number; size:InlineWpSize; blockId:string } /** * Bridge between BlockNote inline components and the host application. @@ -18,34 +18,35 @@ export interface WpToInlinePayload { wpid: number; size: InlineWpSize; blockId: */ class WpBridge { - private readonly listeners = new Map void>>(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private readonly listeners = new Map void>>(); // Typed emit helpers — prefer these over calling emit() directly - resize(payload: WpResizePayload): void { - this.emit("resize", payload); + resize(payload:WpResizePayload):void { + this.emit('resize', payload); } - delete(payload: WpDeletePayload): void { - this.emit("delete", payload); + delete(payload:WpDeletePayload):void { + this.emit('delete', payload); } - convertToInline(payload: WpToInlinePayload): void { - this.emit("toInline", payload); + convertToInline(payload:WpToInlinePayload):void { + this.emit('toInline', payload); } - onResize(callback: (payload: WpResizePayload) => void): () => void { - return this.on("resize", callback); + onResize(callback:(payload:WpResizePayload) => void):() => void { + return this.on('resize', callback); } - onDelete(callback: (payload: WpDeletePayload) => void): () => void { - return this.on("delete", callback); + onDelete(callback:(payload:WpDeletePayload) => void):() => void { + return this.on('delete', callback); } - onConvertToInline(callback: (payload: WpToInlinePayload) => void): () => void { - return this.on("toInline", callback); + onConvertToInline(callback:(payload:WpToInlinePayload) => void):() => void { + return this.on('toInline', callback); } - private emit(event: string, payload: unknown): void { + private emit(event:string, payload:unknown):void { const listeners = this.listeners.get(event); if (!listeners) return; @@ -54,12 +55,13 @@ class WpBridge { try { callback(payload); } catch (error) { - console.error("[WpBridge]", event, { payload, error }); + console.error('[WpBridge]', event, { payload, error }); } } } - private on(event: string, callback: (payload: any) => void): () => void { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private on(event:string, callback:(payload:any) => void):() => void { let set = this.listeners.get(event); if (!set) { set = new Set(); @@ -68,8 +70,8 @@ class WpBridge { set.add(callback); return () => { - set!.delete(callback); - if (set!.size === 0) this.listeners.delete(event); + set.delete(callback); + if (set.size === 0) this.listeners.delete(event); }; } } diff --git a/lib/utils/cursor.ts b/lib/utils/cursor.ts index e6ef2a6..208bbec 100644 --- a/lib/utils/cursor.ts +++ b/lib/utils/cursor.ts @@ -1,26 +1,27 @@ -import type { BlockNoteEditor } from "@blocknote/core"; -import { TextSelection } from "prosemirror-state"; +import type { BlockNoteEditor } from '@blocknote/core'; +import { TextSelection } from 'prosemirror-state'; +// eslint-disable-next-line @typescript-eslint/no-explicit-any type AnyEditor = BlockNoteEditor; -export function moveCursorAfterBlock(editor: AnyEditor, blockId: string): void { +export function moveCursorAfterBlock(editor:AnyEditor, blockId:string):void { editor.focus(); - editor.setTextCursorPosition(blockId, "end"); + editor.setTextCursorPosition(blockId, 'end'); const cursor = editor.getTextCursorPosition(); if (!cursor?.nextBlock && cursor?.block) { - editor.insertBlocks([{ type: "paragraph", content: [] }], cursor.block.id, "after"); + editor.insertBlocks([{ type: 'paragraph', content: [] }], cursor.block.id, 'after'); } const updated = editor.getTextCursorPosition(); if (updated?.nextBlock) { - editor.setTextCursorPosition(updated.nextBlock.id, "start"); + editor.setTextCursorPosition(updated.nextBlock.id, 'start'); } } -export function placeCursorAfterInlineNode(editor: AnyEditor, instanceId: string): void { +export function placeCursorAfterInlineNode(editor:AnyEditor, instanceId:string):void { const { doc } = editor.prosemirrorState; - let targetPos: number | null = null; + let targetPos:number | null = null; doc.descendants((node, pos) => { if (targetPos !== null) return false; diff --git a/lib/utils/id.ts b/lib/utils/id.ts index 521ff19..3236bf7 100644 --- a/lib/utils/id.ts +++ b/lib/utils/id.ts @@ -1,12 +1,12 @@ // Generates a globally unique instance ID for inline work package chips. // Falls back to a timestamp-based ID in environments without crypto.randomUUID -export function makeInstanceId(): string { - return typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" +export function makeInstanceId():string { + return typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' ? crypto.randomUUID() : `iid-${Date.now()}-${Math.random().toString(36).slice(2)}`; } -export function formatWorkPackageId(displayId: string): string { +export function formatWorkPackageId(displayId:string):string { return /^\d+$/.test(displayId) ? `#${displayId}` : displayId; } \ No newline at end of file diff --git a/lib/utils/selection.ts b/lib/utils/selection.ts index 718ab55..00cc668 100644 --- a/lib/utils/selection.ts +++ b/lib/utils/selection.ts @@ -7,19 +7,19 @@ */ type ShadowRootWithSelection = ShadowRoot & { - getSelection: () => Selection | null; + getSelection:() => Selection | null; }; -function hasShadowGetSelection(root: Node): root is ShadowRootWithSelection { - return root instanceof ShadowRoot && "getSelection" in root; +function hasShadowGetSelection(root:Node):root is ShadowRootWithSelection { + return root instanceof ShadowRoot && 'getSelection' in root; } -export function getSelectionForNode(node: Node): Selection | null { +export function getSelectionForNode(node:Node):Selection | null { const root = node.getRootNode(); return hasShadowGetSelection(root) ? root.getSelection() : window.getSelection(); } -export function isNodeInSelection(node: Node): boolean { +export function isNodeInSelection(node:Node):boolean { const selection = getSelectionForNode(node); if (!selection || selection.rangeCount === 0) return false; return selection.getRangeAt(0).intersectsNode(node); diff --git a/package-lock.json b/package-lock.json index 590e1c4..f2835df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "op-blocknote-extensions", - "version": "0.0.25", + "version": "0.0.26", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "op-blocknote-extensions", - "version": "0.0.25", + "version": "0.0.26", "dependencies": { "@blocknote/core": "^0.44.2", "@blocknote/mantine": "^0.44.2", @@ -21,6 +21,7 @@ "@mantine/core": "^8.3.6", "@mantine/hooks": "^8.3.5", "@mantine/utils": "^6.0.22", + "@stylistic/eslint-plugin": "^5.10.0", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", "@types/node": "^24.9.2", @@ -32,6 +33,7 @@ "@vitejs/plugin-react": "^5.1.0", "@vitest/browser-playwright": "^4.0.18", "@vitest/coverage-v8": "^4.0.18", + "@vitest/eslint-plugin": "^1.6.18", "eslint": "^9.38.0", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.19", @@ -1132,9 +1134,9 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", - "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2088,6 +2090,54 @@ "dev": true, "license": "MIT" }, + "node_modules/@stylistic/eslint-plugin": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-5.10.0.tgz", + "integrity": "sha512-nPK52ZHvot8Ju/0A4ucSX1dcPV2/1clx0kLcH5wDmrE4naKso7TUC/voUyU1O9OTKTrR6MYip6LP0ogEMQ9jPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/types": "^8.56.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "estraverse": "^5.3.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "peerDependencies": { + "eslint": "^9.0.0 || ^10.0.0" + } + }, + "node_modules/@stylistic/eslint-plugin/node_modules/@typescript-eslint/types": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.60.0.tgz", + "integrity": "sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@stylistic/eslint-plugin/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/@tanstack/react-store": { "version": "0.7.7", "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.7.7.tgz", @@ -3139,6 +3189,243 @@ } } }, + "node_modules/@vitest/eslint-plugin": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/@vitest/eslint-plugin/-/eslint-plugin-1.6.18.tgz", + "integrity": "sha512-J6U4X0jH3NwTuYouvrJn6I8ypTOU+GhKEjyVwpoPnDuc23usa/xi/R0caWLBbNp3xLy3/rL1YkuJuneTMVV4Mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "^8.58.0", + "@typescript-eslint/utils": "^8.58.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@typescript-eslint/eslint-plugin": "*", + "eslint": ">=8.57.0", + "typescript": ">=5.0.0", + "vitest": "*" + }, + "peerDependenciesMeta": { + "@typescript-eslint/eslint-plugin": { + "optional": true + }, + "typescript": { + "optional": true + }, + "vitest": { + "optional": true + } + } + }, + "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/project-service": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.60.0.tgz", + "integrity": "sha512-aZu74NNKJeUWqCjDddzdiKaS82dgYgV/vmf+Ui3ZdZejmgfXR/q+pRumgobnQ2cCJTgGTWp4ypiwsuofFubavg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.60.0", + "@typescript-eslint/types": "^8.60.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/scope-manager": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.60.0.tgz", + "integrity": "sha512-pFzqhllJMs+jghLQWzV00ds39xLzuyqPSev5pd8f4Ir0rtKR3ZLUB4/4dhjOFighWb9larvtfJvqL+4yKDI3Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.0.tgz", + "integrity": "sha512-BZPR3RGYlAXnly6ymAxfkVn5rCbZzQNou0rxv3GfWZ8cTQp+hhVd73khbGLAd8k1TlAPLISH337M+tAgAnaJDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/types": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.60.0.tgz", + "integrity": "sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.0.tgz", + "integrity": "sha512-3AcZNBGMClm6CXDyo8kYvVGT/sx29sS0oBsIb9oZI2gunA4Vm2M3YHzRLPvsUBBsl+yB5FPtltq7gGH0iTlp9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.60.0", + "@typescript-eslint/tsconfig-utils": "8.60.0", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/utils": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.60.0.tgz", + "integrity": "sha512-HtXuPfrHTyBDkameWpl+vJb1Uevu2tznAyahM1Oc4AENidCLTPiZDWIo4GfcxNdC/RcfGcadzzkqbRG87dUrQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.60.0", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.0.tgz", + "integrity": "sha512-9WI52t8ZGLVGrPMBet25yAftqY/n95+zmoUUtJBBQTKDSKUu7OsPTroT2op7U9JatkoRccL0YkWDNMFfC4Sjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.60.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitest/eslint-plugin/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@vitest/eslint-plugin/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@vitest/eslint-plugin/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitest/eslint-plugin/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@vitest/eslint-plugin/node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@vitest/expect": { "version": "4.0.18", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", @@ -7704,9 +7991,9 @@ } }, "node_modules/ts-api-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", - "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index 5718e1f..276936d 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "@mantine/core": "^8.3.6", "@mantine/hooks": "^8.3.5", "@mantine/utils": "^6.0.22", + "@stylistic/eslint-plugin": "^5.10.0", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", "@types/node": "^24.9.2", @@ -40,6 +41,7 @@ "@vitejs/plugin-react": "^5.1.0", "@vitest/browser-playwright": "^4.0.18", "@vitest/coverage-v8": "^4.0.18", + "@vitest/eslint-plugin": "^1.6.18", "@types/testing-library__react": "^10.0.1", "@types/testing-library__user-event": "^4.1.1", "eslint": "^9.38.0", diff --git a/src/App.tsx b/src/App.tsx index 072f0c3..c04984e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,14 +1,14 @@ -import { useCallback } from "react"; -import { BlockNoteSchema } from "@blocknote/core"; -import { filterSuggestionItems } from "@blocknote/core/extensions"; -import "@blocknote/core/fonts/inter.css"; -import { BlockNoteView } from "@blocknote/mantine"; -import "@blocknote/mantine/style.css"; +import { useCallback } from 'react'; +import { BlockNoteSchema } from '@blocknote/core'; +import { filterSuggestionItems } from '@blocknote/core/extensions'; +import '@blocknote/core/fonts/inter.css'; +import { BlockNoteView } from '@blocknote/mantine'; +import '@blocknote/mantine/style.css'; import { getDefaultReactSlashMenuItems, SuggestionMenuController, useCreateBlockNote, -} from "@blocknote/react"; +} from '@blocknote/react'; import { initializeOpBlockNoteExtensions, openProjectWorkPackageBlockSpec, @@ -16,8 +16,8 @@ import { workPackageSlashMenu, useHashWpMenu, useOpBlockNoteExtensions -} from "../lib"; -import "./fetchOverride"; +} from '../lib'; +import './fetchOverride'; const schema = BlockNoteSchema.create().extend({ blockSpecs: { @@ -29,15 +29,16 @@ const schema = BlockNoteSchema.create().extend({ }); initializeOpBlockNoteExtensions({ - baseUrl: "http://localhost:3000", - locale: "en", + baseUrl: 'http://localhost:3000', + locale: 'en', }); type EditorType = typeof schema.BlockNoteEditor; -function buildSlashMenuItems(editor: EditorType) { +function buildSlashMenuItems(editor:EditorType) { return [ ...getDefaultReactSlashMenuItems(editor), + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument workPackageSlashMenu(editor as any), ]; } @@ -45,13 +46,16 @@ function buildSlashMenuItems(editor: EditorType) { export default function App() { const editor = useCreateBlockNote({ schema }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument useOpBlockNoteExtensions(editor as any); const getSlashItems = useCallback( - async (query: string) => filterSuggestionItems(buildSlashMenuItems(editor), query), + // eslint-disable-next-line @typescript-eslint/require-await + async (query:string) => filterSuggestionItems(buildSlashMenuItems(editor), query), [editor] ); + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument const { getHashItems, HashWpMenu } = useHashWpMenu(editor as any); return ( @@ -68,4 +72,4 @@ export default function App() { /> ); -} \ No newline at end of file +} diff --git a/src/fetchOverride.ts b/src/fetchOverride.ts index c819853..4a0a8b9 100644 --- a/src/fetchOverride.ts +++ b/src/fetchOverride.ts @@ -8,7 +8,7 @@ window.fetch = (url, options = {}) => { return originalFetch(url, { ...options, headers: { - "Authorization": `Basic ${apiKey}`, + 'Authorization': `Basic ${apiKey}`, }, }); }; diff --git a/src/main.tsx b/src/main.tsx index c53b69a..0701836 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,9 +1,9 @@ -import "./main.css"; -import { StrictMode } from "react"; -import { createRoot } from "react-dom/client"; -import App from "./App.tsx"; +import './main.css'; +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import App from './App.tsx'; -createRoot(document.getElementById("root")!).render( +createRoot(document.getElementById('root')!).render( diff --git a/src/op-blocknote.ts b/src/op-blocknote.ts index 3ecb78b..18f2d12 100644 --- a/src/op-blocknote.ts +++ b/src/op-blocknote.ts @@ -6,8 +6,8 @@ import mantineStyles from '@blocknote/mantine/style.css?url'; import { ShadowDomWrapper } from '../lib'; class BlockNoteElement extends HTMLElement { - private mount: HTMLDivElement; - private reactRoot: ReturnType | null = null; + private mount:HTMLDivElement; + private reactRoot:ReturnType | null = null; constructor() { super(); diff --git a/test/helpers/editorHelpers.ts b/test/helpers/editorHelpers.ts index 2290de7..7815a33 100644 --- a/test/helpers/editorHelpers.ts +++ b/test/helpers/editorHelpers.ts @@ -2,14 +2,14 @@ import { expect } from 'vitest'; import { page, userEvent } from 'vitest/browser'; // Insert -export async function openEditorAndType(text: string) { +export async function openEditorAndType(text:string) { const editorEl = page.getByRole('textbox'); await expect.element(editorEl).toBeVisible(); await userEvent.click(editorEl); await userEvent.type(editorEl, text); } -export async function insertInlineChipViaSlashMenu(searchTerm:string='Fix', resultTerm:string='Fix login bug') { +export async function insertInlineChipViaSlashMenu(searchTerm='Fix', resultTerm='Fix login bug') { await openEditorAndType('/'); await expect.element(page.getByText('Link existing work package').first()).toBeVisible(); await userEvent.click(page.getByText('Link existing work package').first()); @@ -26,19 +26,19 @@ export async function insertInlineChipViaSlashMenu(searchTerm:string='Fix', resu await expect.element(page.getByText(resultTerm)).toBeVisible(); } -export async function insertInlineChipViaHash(hashes: string) { +export async function insertInlineChipViaHash(hashes:string) { await openEditorAndType(`${hashes}Fix`); await expect.element(page.getByText('Fix login bug')).toBeVisible(); await userEvent.click(page.getByText('Fix login bug')); } // Inline chip - popover & size menu -export async function openInlineChipPopover(displayId: string = '#123') { +export async function openInlineChipPopover(displayId = '#123') { await userEvent.click(page.getByText(displayId).first()); await expect.element(page.getByTestId('popover-content')).toBeVisible(); } -export async function openInlineChipSizeMenu(displayId:string = '#123') { +export async function openInlineChipSizeMenu(displayId = '#123') { await openInlineChipPopover(displayId); await userEvent.click(page.getByTitle('Change size')); await expect.element(page.getByTestId('size-menu')).toBeVisible(); @@ -56,7 +56,7 @@ export async function openBlockCardSizeMenu() { await expect.element(page.getByTestId('size-menu')).toBeVisible(); } -export async function convertToCompactCard(displayId:string = "#123") { +export async function convertToCompactCard(displayId = '#123') { await openInlineChipSizeMenu(displayId); await userEvent.click(page.getByRole('button', { name: 'Compact card', exact: true })); await expect.element(page.getByTestId('block-card')).toBeVisible(); diff --git a/test/helpers/renderEditor.tsx b/test/helpers/renderEditor.tsx index fdc47fd..7718890 100644 --- a/test/helpers/renderEditor.tsx +++ b/test/helpers/renderEditor.tsx @@ -32,7 +32,7 @@ function Editor() { const { getHashItems, HashWpMenu } = useHashWpMenu(editor as any); const getSlashItems = useCallback( - async (query: string) => + async (query:string) => filterSuggestionItems( [...getDefaultReactSlashMenuItems(editor), workPackageSlashMenu(editor as any)], query diff --git a/test/lib/components/callbacks.test.ts b/test/lib/components/callbacks.test.ts index 8a75d1c..3ba8c13 100644 --- a/test/lib/components/callbacks.test.ts +++ b/test/lib/components/callbacks.test.ts @@ -1,17 +1,17 @@ -import { describe, it, expect, vi } from "vitest"; +import { describe, it, expect, vi } from 'vitest'; import { registerInlineWpCallbacks, getPendingCallbacks, clearInlineWpCallbacks, makePendingWpid, -} from "../../../lib/components/InlineWorkPackage/callbacks"; +} from '../../../lib/components/InlineWorkPackage/callbacks'; -describe("InlineWp callbacks registry", () => { - it("registers and retrieves callbacks via pending wpid", () => { +describe('InlineWp callbacks registry', () => { + it('registers and retrieves callbacks via pending wpid', () => { const onSelect = vi.fn(); const onCancel = vi.fn(); - const key = "key-1"; + const key = 'key-1'; const wpid = makePendingWpid(key); registerInlineWpCallbacks(key, onSelect, onCancel); @@ -23,20 +23,20 @@ describe("InlineWp callbacks registry", () => { clearInlineWpCallbacks(key); }); - it("returns undefined for unknown key", () => { - const wpid = makePendingWpid("nonexistent"); + it('returns undefined for unknown key', () => { + const wpid = makePendingWpid('nonexistent'); expect(getPendingCallbacks(wpid)).toBeUndefined(); }); - it("returns undefined for non-pending wpid", () => { - expect(getPendingCallbacks("123")).toBeUndefined(); + it('returns undefined for non-pending wpid', () => { + expect(getPendingCallbacks('123')).toBeUndefined(); }); - it("clears callbacks by key", () => { + it('clears callbacks by key', () => { const onSelect = vi.fn(); const onCancel = vi.fn(); - const key = "key-2"; + const key = 'key-2'; const wpid = makePendingWpid(key); registerInlineWpCallbacks(key, onSelect, onCancel); @@ -45,12 +45,12 @@ describe("InlineWp callbacks registry", () => { expect(getPendingCallbacks(wpid)).toBeUndefined(); }); - it("overwrites existing callbacks for the same key", () => { + it('overwrites existing callbacks for the same key', () => { const onSelect1 = vi.fn(); const onSelect2 = vi.fn(); const onCancel = vi.fn(); - const key = "key-3"; + const key = 'key-3'; const wpid = makePendingWpid(key); registerInlineWpCallbacks(key, onSelect1, onCancel); diff --git a/test/lib/components/hashMenu.test.ts b/test/lib/components/hashMenu.test.ts index 46c4c1f..073283d 100644 --- a/test/lib/components/hashMenu.test.ts +++ b/test/lib/components/hashMenu.test.ts @@ -1,15 +1,15 @@ // @vitest-environment jsdom -import { describe, it, expect } from "vitest"; -import { BlockNoteEditor, BlockNoteSchema } from "@blocknote/core"; +import { describe, it, expect } from 'vitest'; +import { BlockNoteEditor, BlockNoteSchema } from '@blocknote/core'; import { openProjectWorkPackageBlockSpec, openProjectWorkPackageInlineSpec, -} from "../../../lib"; +} from '../../../lib'; import { getSizeFromCurrentBlock, insertWpChip, removeTriggerBeforeChip, -} from "../../../lib/components/HashMenu/editorUtils"; +} from '../../../lib/components/HashMenu/editorUtils'; const schema = BlockNoteSchema.create().extend({ blockSpecs: { @@ -20,181 +20,181 @@ const schema = BlockNoteSchema.create().extend({ }, }); -function createTestEditor(text: string) { +function createTestEditor(text:string) { const editor = BlockNoteEditor.create({ schema, - initialContent: [{ type: "paragraph", content: text }], + initialContent: [{ type: 'paragraph', content: text }], }); const block = editor.document[0]; - editor.setTextCursorPosition(block, "end"); + editor.setTextCursorPosition(block, 'end'); return editor; } -function createEditorWithContent(content: any[]) { +function createEditorWithContent(content:any[]) { return BlockNoteEditor.create({ schema, initialContent: [ { - type: "paragraph", + type: 'paragraph', content, } as any, ], }); } -describe("getSizeFromCurrentBlock", () => { - it("returns xxs for #", () => { - const editor = createTestEditor("#foo"); - expect(getSizeFromCurrentBlock(editor as any)).toBe("xxs"); +describe('getSizeFromCurrentBlock', () => { + it('returns xxs for #', () => { + const editor = createTestEditor('#foo'); + expect(getSizeFromCurrentBlock(editor as any)).toBe('xxs'); }); - it("returns xs for ##", () => { - const editor = createTestEditor("##foo"); - expect(getSizeFromCurrentBlock(editor as any)).toBe("xs"); + it('returns xs for ##', () => { + const editor = createTestEditor('##foo'); + expect(getSizeFromCurrentBlock(editor as any)).toBe('xs'); }); - it("returns s for ### or more", () => { - expect(getSizeFromCurrentBlock(createTestEditor("###foo") as any)).toBe("s"); - expect(getSizeFromCurrentBlock(createTestEditor("####foo") as any)).toBe("s"); + it('returns s for ### or more', () => { + expect(getSizeFromCurrentBlock(createTestEditor('###foo') as any)).toBe('s'); + expect(getSizeFromCurrentBlock(createTestEditor('####foo') as any)).toBe('s'); }); - it("returns xxs if no hashes", () => { - const editor = createTestEditor("foo"); - expect(getSizeFromCurrentBlock(editor as any)).toBe("xxs"); + it('returns xxs if no hashes', () => { + const editor = createTestEditor('foo'); + expect(getSizeFromCurrentBlock(editor as any)).toBe('xxs'); }); - it("uses the last hash trigger in the block", () => { - const editor = createTestEditor("#first ##bug"); - expect(getSizeFromCurrentBlock(editor as any)).toBe("xs"); + it('uses the last hash trigger in the block', () => { + const editor = createTestEditor('#first ##bug'); + expect(getSizeFromCurrentBlock(editor as any)).toBe('xs'); }); }); -describe("removeTriggerBeforeChip", () => { - it("removes the trailing # before the chip", () => { +describe('removeTriggerBeforeChip', () => { + it('removes the trailing # before the chip', () => { const editor = createEditorWithContent([ - { type: "text", text: "Hello #", styles: {} }, + { type: 'text', text: 'Hello #', styles: {} }, { - type: "openProjectWorkPackageInline", - props: { wpid: "1", instanceId: "test-iid", size: "xxs" }, + type: 'openProjectWorkPackageInline', + props: { wpid: '1', instanceId: 'test-iid', size: 'xxs' }, }, ]); - removeTriggerBeforeChip(editor as any, "test-iid"); + removeTriggerBeforeChip(editor as any, 'test-iid'); const block = editor.getBlock(editor.document[0].id); - expect((block?.content as any)[0].text).toBe("Hello "); + expect((block?.content as any)[0].text).toBe('Hello '); }); - it("removes multiple trailing hashes (##, ###) before the chip", () => { + it('removes multiple trailing hashes (##, ###) before the chip', () => { const editor = createEditorWithContent([ - { type: "text", text: "Hello ###", styles: {} }, + { type: 'text', text: 'Hello ###', styles: {} }, { - type: "openProjectWorkPackageInline", - props: { wpid: "1", instanceId: "test-iid", size: "s" }, + type: 'openProjectWorkPackageInline', + props: { wpid: '1', instanceId: 'test-iid', size: 's' }, }, ]); - removeTriggerBeforeChip(editor as any, "test-iid"); + removeTriggerBeforeChip(editor as any, 'test-iid'); const block = editor.getBlock(editor.document[0].id); - expect((block?.content as any)[0].text).toBe("Hello "); + expect((block?.content as any)[0].text).toBe('Hello '); }); - it("leaves earlier # in the line alone — removes only the trigger # nearest to the chip", () => { + it('leaves earlier # in the line alone — removes only the trigger # nearest to the chip', () => { const editor = createEditorWithContent([ - { type: "text", text: "Pre #one #two #", styles: {} }, + { type: 'text', text: 'Pre #one #two #', styles: {} }, { - type: "openProjectWorkPackageInline", - props: { wpid: "1", instanceId: "test-iid", size: "xxs" }, + type: 'openProjectWorkPackageInline', + props: { wpid: '1', instanceId: 'test-iid', size: 'xxs' }, }, ]); - removeTriggerBeforeChip(editor as any, "test-iid"); + removeTriggerBeforeChip(editor as any, 'test-iid'); const block = editor.getBlock(editor.document[0].id); - expect((block?.content as any)[0].text).toBe("Pre #one #two "); + expect((block?.content as any)[0].text).toBe('Pre #one #two '); }); - it("removes the previous text node entirely when only # remains", () => { + it('removes the previous text node entirely when only # remains', () => { const editor = createEditorWithContent([ - { type: "text", text: "#", styles: {} }, + { type: 'text', text: '#', styles: {} }, { - type: "openProjectWorkPackageInline", - props: { wpid: "1", instanceId: "test-iid", size: "xxs" }, + type: 'openProjectWorkPackageInline', + props: { wpid: '1', instanceId: 'test-iid', size: 'xxs' }, }, ]); - removeTriggerBeforeChip(editor as any, "test-iid"); + removeTriggerBeforeChip(editor as any, 'test-iid'); const block = editor.getBlock(editor.document[0].id); - expect((block?.content as any)[0].type).toBe("openProjectWorkPackageInline"); + expect((block?.content as any)[0].type).toBe('openProjectWorkPackageInline'); }); - it("does nothing if the chip is not found", () => { - const editor = createTestEditor("Hello #foo"); + it('does nothing if the chip is not found', () => { + const editor = createTestEditor('Hello #foo'); const before = JSON.stringify(editor.document); - removeTriggerBeforeChip(editor as any, "nonexistent-iid"); + removeTriggerBeforeChip(editor as any, 'nonexistent-iid'); expect(JSON.stringify(editor.document)).toBe(before); }); - it("does nothing when there is no preceding text node", () => { + it('does nothing when there is no preceding text node', () => { const editor = createEditorWithContent([ { - type: "openProjectWorkPackageInline", - props: { wpid: "1", instanceId: "test-iid", size: "xxs" }, + type: 'openProjectWorkPackageInline', + props: { wpid: '1', instanceId: 'test-iid', size: 'xxs' }, }, ]); const before = JSON.stringify(editor.document); - removeTriggerBeforeChip(editor as any, "test-iid"); + removeTriggerBeforeChip(editor as any, 'test-iid'); expect(JSON.stringify(editor.document)).toBe(before); }); }); -describe("insertWpChip", () => { - it("inserts a chip with the work package ID and size", () => { - const editor = createTestEditor("test "); +describe('insertWpChip', () => { + it('inserts a chip with the work package ID and size', () => { + const editor = createTestEditor('test '); insertWpChip( editor as any, - { id: 1, subject: "Fix bug" } as any, - "xxs", + { id: 1, subject: 'Fix bug' } as any, + 'xxs', ); const block = editor.getBlock(editor.document[0].id); const chip = (block?.content as any[]).find( - (n) => n.type === "openProjectWorkPackageInline", + (n) => n.type === 'openProjectWorkPackageInline', ); expect(chip).toBeDefined(); - expect(chip.props.wpid).toBe("1"); - expect(chip.props.size).toBe("xxs"); + expect(chip.props.wpid).toBe('1'); + expect(chip.props.size).toBe('xxs'); expect(chip.props.instanceId).toEqual(expect.any(String)); }); - it("inserts a trailing space after the chip", () => { - const editor = createTestEditor("test "); + it('inserts a trailing space after the chip', () => { + const editor = createTestEditor('test '); insertWpChip( editor as any, - { id: 1, subject: "Fix bug" } as any, - "xxs", + { id: 1, subject: 'Fix bug' } as any, + 'xxs', ); const block = editor.getBlock(editor.document[0].id); const content = block?.content as any[]; const chipIdx = content.findIndex( - (n) => n.type === "openProjectWorkPackageInline", + (n) => n.type === 'openProjectWorkPackageInline', ); const afterChip = content[chipIdx + 1]; - expect(afterChip?.type).toBe("text"); - expect(afterChip?.text).toBe(" "); + expect(afterChip?.type).toBe('text'); + expect(afterChip?.text).toBe(' '); }); }); \ No newline at end of file diff --git a/test/lib/components/integration/searchDropdown.browser.test.tsx b/test/lib/components/integration/searchDropdown.browser.test.tsx index c0a4061..a4eb729 100644 --- a/test/lib/components/integration/searchDropdown.browser.test.tsx +++ b/test/lib/components/integration/searchDropdown.browser.test.tsx @@ -1,12 +1,12 @@ import { describe, it, expect, vi } from 'vitest'; import { render } from 'vitest-browser-react'; import { page, userEvent } from 'vitest/browser'; -import { SearchDropdown } from '../../../../lib/components/Search/SearchDropdown' +import { SearchDropdown } from '../../../../lib/components/Search/SearchDropdown'; import { BlockCard } from '../../../../lib/components/BlockWorkPackage/BlockCard'; import { mockWorkPackage } from '../../../mocks/handlers'; import type { WorkPackage } from '../../../../lib/openProjectTypes'; -const renderItem = (wp: WorkPackage) => ; +const renderItem = (wp:WorkPackage) => ; describe('SearchDropdown', () => { it('shows results after typing', async () => { @@ -33,8 +33,7 @@ describe('SearchDropdown', () => { await userEvent.click(page.getByText('Fix login bug')); - expect(onSelect).toHaveBeenCalledOnce(); - expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: mockWorkPackage.id })); + expect(onSelect).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ id: mockWorkPackage.id })); }); it('calls onCancel when pressing Escape', async () => { @@ -61,8 +60,7 @@ describe('SearchDropdown', () => { await userEvent.keyboard('{ArrowDown}{Enter}'); - expect(onSelect).toHaveBeenCalledOnce(); - expect(onSelect).toHaveBeenCalledWith( + expect(onSelect).toHaveBeenCalledExactlyOnceWith( expect.objectContaining({ id: mockWorkPackage.id }) ); }); diff --git a/test/lib/components/integration/wpOptionsPopover.browser.test.tsx b/test/lib/components/integration/wpOptionsPopover.browser.test.tsx index 68c9441..98311ec 100644 --- a/test/lib/components/integration/wpOptionsPopover.browser.test.tsx +++ b/test/lib/components/integration/wpOptionsPopover.browser.test.tsx @@ -15,10 +15,10 @@ afterEach(() => { cleanup(); }); -function ChipWrapper({ initialSize, wpid = '123', instanceId = 'iid-test' }: { - initialSize: string; - wpid?: string; - instanceId?: string; +function ChipWrapper({ initialSize, wpid = '123', instanceId = 'iid-test' }:{ + initialSize:string; + wpid?:string; + instanceId?:string; }) { const [size, setSize] = useState(initialSize); diff --git a/test/lib/components/slashMenu.test.ts b/test/lib/components/slashMenu.test.ts index 4a01763..98d095e 100644 --- a/test/lib/components/slashMenu.test.ts +++ b/test/lib/components/slashMenu.test.ts @@ -1,58 +1,58 @@ // @vitest-environment jsdom -import { describe, it, expect } from "vitest"; -import { workPackageSlashMenu } from "../../../lib/components/SlashMenu"; -import i18n from "../../../lib/services/i18n"; +import { describe, it, expect } from 'vitest'; +import { workPackageSlashMenu } from '../../../lib/components/SlashMenu'; +import i18n from '../../../lib/services/i18n'; -const setLang = async (lang: string) => i18n.changeLanguage(lang); +const setLang = async (lang:string) => i18n.changeLanguage(lang); -describe("workPackageSlashMenu", () => { - it("is translated to German", async () => { - await setLang("de"); +describe('workPackageSlashMenu', () => { + it('is translated to German', async () => { + await setLang('de'); const slashMenu = workPackageSlashMenu({} as any); - expect(slashMenu.title).toBe("Vorhandenes Arbeitspaket verlinken"); + expect(slashMenu.title).toBe('Vorhandenes Arbeitspaket verlinken'); }); - it("is translated to English", async () => { - await setLang("en"); + it('is translated to English', async () => { + await setLang('en'); const slashMenu = workPackageSlashMenu({} as any); - expect(slashMenu.title).toBe("Link existing work package"); + expect(slashMenu.title).toBe('Link existing work package'); }); - it("calculates all possible aliases for the slash menu", async () => { - await setLang("en"); + it('calculates all possible aliases for the slash menu', async () => { + await setLang('en'); const slashMenu = workPackageSlashMenu({} as any); const actual = slashMenu.aliases; const expected = [ - "openproject work package link", "openproject workpackage link", "openproject wp link", - "op work package link", "op workpackage link", "op wp link", - "openproject link work package", "openproject link workpackage", "openproject link wp", - "op link work package", "op link workpackage", "op link wp", - "work package openproject link", "work package op link", - "workpackage openproject link", "workpackage op link", - "wp openproject link", "wp op link", - "work package link openproject", "work package link op", - "workpackage link openproject", "workpackage link op", - "wp link openproject", "wp link op", - "link work package openproject", "link work package op", - "link workpackage openproject", "link workpackage op", - "link wp openproject", "link wp op", - "link openproject work package", "link openproject workpackage", "link openproject wp", - "link op work package", "link op workpackage", "link op wp", + 'openproject work package link', 'openproject workpackage link', 'openproject wp link', + 'op work package link', 'op workpackage link', 'op wp link', + 'openproject link work package', 'openproject link workpackage', 'openproject link wp', + 'op link work package', 'op link workpackage', 'op link wp', + 'work package openproject link', 'work package op link', + 'workpackage openproject link', 'workpackage op link', + 'wp openproject link', 'wp op link', + 'work package link openproject', 'work package link op', + 'workpackage link openproject', 'workpackage link op', + 'wp link openproject', 'wp link op', + 'link work package openproject', 'link work package op', + 'link workpackage openproject', 'link workpackage op', + 'link wp openproject', 'link wp op', + 'link openproject work package', 'link openproject workpackage', 'link openproject wp', + 'link op work package', 'link op workpackage', 'link op wp', ]; expect(actual.sort()).toEqual(expected.sort()); }); - it("keeps English aliases even when language is German", async () => { - await setLang("de"); + it('keeps English aliases even when language is German', async () => { + await setLang('de'); const slashMenu = workPackageSlashMenu({} as any); const actual = slashMenu.aliases; - expect(actual).toContain("openproject work package link"); - expect(actual).toContain("openproject Arbeitspaket link"); - expect(actual).toContain("openproject wp link"); - expect(actual).toContain("openproject ap link"); + expect(actual).toContain('openproject work package link'); + expect(actual).toContain('openproject Arbeitspaket link'); + expect(actual).toContain('openproject wp link'); + expect(actual).toContain('openproject ap link'); - await setLang("en"); + await setLang('en'); }); }); \ No newline at end of file diff --git a/test/lib/services/openProjectApi.test.ts b/test/lib/services/openProjectApi.test.ts index fc28dd0..1c1bf8c 100644 --- a/test/lib/services/openProjectApi.test.ts +++ b/test/lib/services/openProjectApi.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi } from 'vitest'; import { fetchStatuses, fetchTypes, @@ -7,74 +7,74 @@ import { linkToWorkPackage, OpenProjectApiError, searchWorkPackages -} from "../../../lib/services/openProjectApi"; +} from '../../../lib/services/openProjectApi'; -describe("openProjectApi", () => { - it("works with a baseUrl with trailing slash", () => { - initOpenProjectApi({baseUrl: "https://example.com/"}); - expect(linkToWorkPackage("42")).toBe("https://example.com/wp/42"); +describe('openProjectApi', () => { + it('works with a baseUrl with trailing slash', () => { + initOpenProjectApi({baseUrl: 'https://example.com/'}); + expect(linkToWorkPackage('42')).toBe('https://example.com/wp/42'); }); - it("works with a baseUrl without trailing slash", () => { - initOpenProjectApi({baseUrl: "https://example.com"}); - expect(linkToWorkPackage("42")).toBe("https://example.com/wp/42"); + it('works with a baseUrl without trailing slash', () => { + initOpenProjectApi({baseUrl: 'https://example.com'}); + expect(linkToWorkPackage('42')).toBe('https://example.com/wp/42'); }); - describe("searchWorkPackages", () => { - it("should fetch work packages sorted by updatedAt descending", () => { - initOpenProjectApi({baseUrl: "http://localhost:3000"}); + describe('searchWorkPackages', () => { + it('should fetch work packages sorted by updatedAt descending', () => { + initOpenProjectApi({baseUrl: 'http://localhost:3000'}); const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValue({ ok: true, json: async () => ({ _embedded: { elements: [] } }), } as Response); try { - searchWorkPackages("test query"); + searchWorkPackages('test query'); const calledUrl = fetchSpy.mock.calls[0][0] as string; - expect(calledUrl).toContain("sortBy=%5B%5B%22updatedAt%22%2C%22desc%22%5D%5D"); + expect(calledUrl).toContain('sortBy=%5B%5B%22updatedAt%22%2C%22desc%22%5D%5D'); } finally { fetchSpy.mockRestore(); } - }) + }); }); - describe("linkToWorkPackage", () => { - it("builds a correct URL for a numeric displayId", () => { - initOpenProjectApi({baseUrl: "https://example.com"}); - expect(linkToWorkPackage("123")).toBe("https://example.com/wp/123"); - expect(linkToWorkPackage("42")).toBe("https://example.com/wp/42"); + describe('linkToWorkPackage', () => { + it('builds a correct URL for a numeric displayId', () => { + initOpenProjectApi({baseUrl: 'https://example.com'}); + expect(linkToWorkPackage('123')).toBe('https://example.com/wp/123'); + expect(linkToWorkPackage('42')).toBe('https://example.com/wp/42'); }); - it("builds a correct URL for a semantic displayId", () => { - initOpenProjectApi({baseUrl: "https://example.com"}); - expect(linkToWorkPackage("DWPS-1")).toBe("https://example.com/wp/DWPS-1"); - expect(linkToWorkPackage("PROJ-42")).toBe("https://example.com/wp/PROJ-42"); + it('builds a correct URL for a semantic displayId', () => { + initOpenProjectApi({baseUrl: 'https://example.com'}); + expect(linkToWorkPackage('DWPS-1')).toBe('https://example.com/wp/DWPS-1'); + expect(linkToWorkPackage('PROJ-42')).toBe('https://example.com/wp/PROJ-42'); }); - it("encodes path traversal attempts via encodeURIComponent", () => { - initOpenProjectApi({baseUrl: "https://example.com"}); - expect(linkToWorkPackage("../secret")).toBe("https://example.com/wp/..%2Fsecret"); - expect(linkToWorkPackage("../../etc/passwd")).toBe("https://example.com/wp/..%2F..%2Fetc%2Fpasswd"); - expect(linkToWorkPackage("foo/bar")).toBe("https://example.com/wp/foo%2Fbar"); + it('encodes path traversal attempts via encodeURIComponent', () => { + initOpenProjectApi({baseUrl: 'https://example.com'}); + expect(linkToWorkPackage('../secret')).toBe('https://example.com/wp/..%2Fsecret'); + expect(linkToWorkPackage('../../etc/passwd')).toBe('https://example.com/wp/..%2F..%2Fetc%2Fpasswd'); + expect(linkToWorkPackage('foo/bar')).toBe('https://example.com/wp/foo%2Fbar'); }); }); - describe("fetchWorkPackage", () => { - it("rejects for invalid work package ID", async () => { - initOpenProjectApi({baseUrl: "https://example.com"}); - await expect(fetchWorkPackage(-1)).rejects.toHaveProperty("message", "Invalid work package ID: -1"); - await expect(fetchWorkPackage(0)).rejects.toHaveProperty("message", "Invalid work package ID: 0"); - await expect(fetchWorkPackage(NaN)).rejects.toHaveProperty("message", "Invalid work package ID: NaN"); - await expect(fetchWorkPackage("abublé" as unknown as number)).rejects.toHaveProperty("message", "Invalid work package ID: abublé"); + describe('fetchWorkPackage', () => { + it('rejects for invalid work package ID', async () => { + initOpenProjectApi({baseUrl: 'https://example.com'}); + await expect(fetchWorkPackage(-1)).rejects.toHaveProperty('message', 'Invalid work package ID: -1'); + await expect(fetchWorkPackage(0)).rejects.toHaveProperty('message', 'Invalid work package ID: 0'); + await expect(fetchWorkPackage(NaN)).rejects.toHaveProperty('message', 'Invalid work package ID: NaN'); + await expect(fetchWorkPackage('abublé' as unknown as number)).rejects.toHaveProperty('message', 'Invalid work package ID: abublé'); }); }); - describe("fetchStatuses", () => { - it("resolves with data on success", async () => { - initOpenProjectApi({ baseUrl: "http://localhost:3000" }); + describe('fetchStatuses', () => { + it('resolves with data on success', async () => { + initOpenProjectApi({ baseUrl: 'http://localhost:3000' }); const mockData = { _embedded: { elements: [] } }; - const fetchSpy = vi.spyOn(global, "fetch").mockResolvedValue({ + const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValue({ ok: true, json: async () => mockData, } as Response); @@ -87,19 +87,19 @@ describe("openProjectApi", () => { } }); - it("logs to console and rejects on HTTP error", async () => { - initOpenProjectApi({ baseUrl: "http://localhost:3000" }); - const fetchSpy = vi.spyOn(global, "fetch").mockResolvedValue({ + it('logs to console and rejects on HTTP error', async () => { + initOpenProjectApi({ baseUrl: 'http://localhost:3000' }); + const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValue({ ok: false, status: 500, - statusText: "Internal Server Error", + statusText: 'Internal Server Error', } as Response); - const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); try { await expect(fetchStatuses()).rejects.toBeInstanceOf(OpenProjectApiError); expect(consoleSpy).toHaveBeenCalledWith( - "[OpenProjectApi] fetchStatuses failed:", + '[OpenProjectApi] fetchStatuses failed:', expect.any(OpenProjectApiError) ); } finally { @@ -109,11 +109,11 @@ describe("openProjectApi", () => { }); }); - describe("fetchTypes", () => { - it("resolves with data on success", async () => { - initOpenProjectApi({ baseUrl: "http://localhost:3000" }); + describe('fetchTypes', () => { + it('resolves with data on success', async () => { + initOpenProjectApi({ baseUrl: 'http://localhost:3000' }); const mockData = { _embedded: { elements: [] } }; - const fetchSpy = vi.spyOn(global, "fetch").mockResolvedValue({ + const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValue({ ok: true, json: async () => mockData, } as Response); @@ -126,19 +126,19 @@ describe("openProjectApi", () => { } }); - it("logs to console and rejects on HTTP error", async () => { - initOpenProjectApi({ baseUrl: "http://localhost:3000" }); - const fetchSpy = vi.spyOn(global, "fetch").mockResolvedValue({ + it('logs to console and rejects on HTTP error', async () => { + initOpenProjectApi({ baseUrl: 'http://localhost:3000' }); + const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValue({ ok: false, status: 403, - statusText: "Forbidden", + statusText: 'Forbidden', } as Response); - const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); try { await expect(fetchTypes()).rejects.toBeInstanceOf(OpenProjectApiError); expect(consoleSpy).toHaveBeenCalledWith( - "[OpenProjectApi] fetchTypes failed:", + '[OpenProjectApi] fetchTypes failed:', expect.any(OpenProjectApiError) ); } finally { diff --git a/test/lib/services/wpBridge.test.ts b/test/lib/services/wpBridge.test.ts index 5cd062e..3c3f76d 100644 --- a/test/lib/services/wpBridge.test.ts +++ b/test/lib/services/wpBridge.test.ts @@ -1,24 +1,24 @@ -import { describe, it, expect, vi } from "vitest"; -import { wpBridge } from "../../../lib/services/wpBridge"; +import { describe, it, expect, vi } from 'vitest'; +import { wpBridge } from '../../../lib/services/wpBridge'; -describe("WpBridge", () => { - it("unsubscribes correctly — listener not called after off()", () => { +describe('WpBridge', () => { + it('unsubscribes correctly — listener not called after off()', () => { const cb = vi.fn(); const off = wpBridge.onResize(cb); off(); - wpBridge.resize({ instanceId: "abc", wpid: 1, size: "s" }); + wpBridge.resize({ instanceId: 'abc', wpid: 1, size: 's' }); expect(cb).not.toHaveBeenCalled(); }); - it("supports multiple listeners for the same event", () => { + it('supports multiple listeners for the same event', () => { const cb1 = vi.fn(); const cb2 = vi.fn(); const off1 = wpBridge.onResize(cb1); const off2 = wpBridge.onResize(cb2); - wpBridge.resize({ instanceId: "abc", wpid: 1, size: "s" }); + wpBridge.resize({ instanceId: 'abc', wpid: 1, size: 's' }); expect(cb1).toHaveBeenCalledOnce(); expect(cb2).toHaveBeenCalledOnce(); @@ -27,13 +27,13 @@ describe("WpBridge", () => { off2(); }); - it("does not call other event listeners when one event fires", () => { + it('does not call other event listeners when one event fires', () => { const resizeCb = vi.fn(); const deleteCb = vi.fn(); const off1 = wpBridge.onResize(resizeCb); const off2 = wpBridge.onDelete(deleteCb); - wpBridge.resize({ instanceId: "abc", wpid: 1, size: "s" }); + wpBridge.resize({ instanceId: 'abc', wpid: 1, size: 's' }); expect(resizeCb).toHaveBeenCalledOnce(); expect(deleteCb).not.toHaveBeenCalled(); @@ -42,13 +42,13 @@ describe("WpBridge", () => { off2(); }); - it("does not throw if a listener throws — other listeners still called", () => { - const badCb = vi.fn().mockImplementation(() => { throw new Error("oops"); }); + it('does not throw if a listener throws — other listeners still called', () => { + const badCb = vi.fn().mockImplementation(() => { throw new Error('oops'); }); const goodCb = vi.fn(); const off1 = wpBridge.onResize(badCb); const off2 = wpBridge.onResize(goodCb); - expect(() => wpBridge.resize({ instanceId: "abc", wpid: 1, size: "s" })).not.toThrow(); + expect(() => wpBridge.resize({ instanceId: 'abc', wpid: 1, size: 's' })).not.toThrow(); expect(goodCb).toHaveBeenCalledOnce(); off1(); diff --git a/test/lib/utils/id.test.ts b/test/lib/utils/id.test.ts index 5bb040c..50edc56 100644 --- a/test/lib/utils/id.test.ts +++ b/test/lib/utils/id.test.ts @@ -1,27 +1,27 @@ -import { describe, it, expect } from "vitest"; -import { makeInstanceId, formatWorkPackageId } from "../../../lib/utils/id.ts"; +import { describe, it, expect } from 'vitest'; +import { makeInstanceId, formatWorkPackageId } from '../../../lib/utils/id.ts'; -describe("formatWorkPackageId", () => { - it("prepends # to a purely numeric string", () => { - expect(formatWorkPackageId("123")).toBe("#123"); - expect(formatWorkPackageId("37")).toBe("#37"); - expect(formatWorkPackageId("1")).toBe("#1"); +describe('formatWorkPackageId', () => { + it('prepends # to a purely numeric string', () => { + expect(formatWorkPackageId('123')).toBe('#123'); + expect(formatWorkPackageId('37')).toBe('#37'); + expect(formatWorkPackageId('1')).toBe('#1'); }); - it("returns alphanumeric displayId as-is without # prefix", () => { - expect(formatWorkPackageId("DWPS-1")).toBe("DWPS-1"); - expect(formatWorkPackageId("ABC123")).toBe("ABC123"); - expect(formatWorkPackageId("PROJ-42")).toBe("PROJ-42"); + it('returns alphanumeric displayId as-is without # prefix', () => { + expect(formatWorkPackageId('DWPS-1')).toBe('DWPS-1'); + expect(formatWorkPackageId('ABC123')).toBe('ABC123'); + expect(formatWorkPackageId('PROJ-42')).toBe('PROJ-42'); }); }); -describe("makeInstanceId", () => { - it("returns a non-empty string", () => { - expect(typeof makeInstanceId()).toBe("string"); +describe('makeInstanceId', () => { + it('returns a non-empty string', () => { + expect(typeof makeInstanceId()).toBe('string'); expect(makeInstanceId().length).toBeGreaterThan(0); }); - it("returns a unique ID on each call", () => { + it('returns a unique ID on each call', () => { const ids = Array.from({ length: 100 }, () => makeInstanceId()); for (let i = 0; i < ids.length - 1; i++) { diff --git a/test/lib/utils/selection.test.ts b/test/lib/utils/selection.test.ts index 64c5fb6..80673e1 100644 --- a/test/lib/utils/selection.test.ts +++ b/test/lib/utils/selection.test.ts @@ -1,15 +1,15 @@ // @vitest-environment jsdom -import { describe, it, expect, beforeEach, vi } from "vitest"; -import { getSelectionForNode, isNodeInSelection } from "../../../lib/utils/selection.ts"; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { getSelectionForNode, isNodeInSelection } from '../../../lib/utils/selection.ts'; -describe("getSelectionForNode", () => { +describe('getSelectionForNode', () => { beforeEach(() => { - document.body.innerHTML = ""; + document.body.innerHTML = ''; }); - it("returns window.getSelection() for a node in the light DOM", () => { - const node = document.createElement("span"); + it('returns window.getSelection() for a node in the light DOM', () => { + const node = document.createElement('span'); document.body.appendChild(node); const result = getSelectionForNode(node); @@ -18,16 +18,16 @@ describe("getSelectionForNode", () => { }); it("returns the shadow root's selection when the node is inside a shadow tree", () => { - const host = document.createElement("div"); + const host = document.createElement('div'); document.body.appendChild(host); - const shadow = host.attachShadow({ mode: "open" }); + const shadow = host.attachShadow({ mode: 'open' }); - const node = document.createElement("span"); + const node = document.createElement('span'); shadow.appendChild(node); const fakeSelection = { rangeCount: 0 } as unknown as Selection; const getSelectionSpy = vi.fn(() => fakeSelection); - (shadow as ShadowRoot & { getSelection: () => Selection | null }).getSelection = getSelectionSpy; + (shadow as ShadowRoot & { getSelection:() => Selection | null }).getSelection = getSelectionSpy; const result = getSelectionForNode(node); @@ -35,12 +35,12 @@ describe("getSelectionForNode", () => { expect(result).toBe(fakeSelection); }); - it("falls back to window.getSelection() when the shadow root has no getSelection method", () => { - const host = document.createElement("div"); + it('falls back to window.getSelection() when the shadow root has no getSelection method', () => { + const host = document.createElement('div'); document.body.appendChild(host); - host.attachShadow({ mode: "open" }); + host.attachShadow({ mode: 'open' }); - const node = document.createElement("span"); + const node = document.createElement('span'); host.shadowRoot!.appendChild(node); const result = getSelectionForNode(node); @@ -49,13 +49,13 @@ describe("getSelectionForNode", () => { }); }); -describe("isNodeInSelection", () => { +describe('isNodeInSelection', () => { beforeEach(() => { - document.body.innerHTML = ""; + document.body.innerHTML = ''; }); - it("returns false when there is no selection", () => { - const node = document.createElement("span"); + it('returns false when there is no selection', () => { + const node = document.createElement('span'); document.body.appendChild(node); window.getSelection()?.removeAllRanges(); @@ -63,23 +63,23 @@ describe("isNodeInSelection", () => { expect(isNodeInSelection(node)).toBe(false); }); - it("returns false when the selection has no ranges", () => { - const host = document.createElement("div"); + it('returns false when the selection has no ranges', () => { + const host = document.createElement('div'); document.body.appendChild(host); - const shadow = host.attachShadow({ mode: "open" }); + const shadow = host.attachShadow({ mode: 'open' }); - const node = document.createElement("span"); + const node = document.createElement('span'); shadow.appendChild(node); - (shadow as ShadowRoot & { getSelection: () => Selection | null }).getSelection = () => + (shadow as ShadowRoot & { getSelection:() => Selection | null }).getSelection = () => ({ rangeCount: 0 } as unknown as Selection); expect(isNodeInSelection(node)).toBe(false); }); - it("returns true when the current range intersects the node", () => { - const container = document.createElement("p"); - container.textContent = "hello world"; + it('returns true when the current range intersects the node', () => { + const container = document.createElement('p'); + container.textContent = 'hello world'; document.body.appendChild(container); const range = document.createRange(); @@ -91,11 +91,11 @@ describe("isNodeInSelection", () => { expect(isNodeInSelection(container)).toBe(true); }); - it("returns false when the current range does not intersect the node", () => { - const inside = document.createElement("span"); - inside.textContent = "selected"; - const outside = document.createElement("span"); - outside.textContent = "not selected"; + it('returns false when the current range does not intersect the node', () => { + const inside = document.createElement('span'); + inside.textContent = 'selected'; + const outside = document.createElement('span'); + outside.textContent = 'not selected'; document.body.append(inside, outside); const range = document.createRange(); diff --git a/test/mocks/handlers.ts b/test/mocks/handlers.ts index 3d68725..6b5abe3 100644 --- a/test/mocks/handlers.ts +++ b/test/mocks/handlers.ts @@ -2,7 +2,7 @@ import { http, HttpResponse } from 'msw'; export const mockWorkPackage = { id: 123, - displayId: "123", + displayId: '123', subject: 'Fix login bug', _links: { self: { href: '/api/v3/work_packages/123' }, @@ -13,7 +13,7 @@ export const mockWorkPackage = { export const mockWorkPackage2 = { id: 456, - displayId: "456", + displayId: '456', subject: 'Add dark mode', _links: { self: { href: '/api/v3/work_packages/456' }, @@ -24,7 +24,7 @@ export const mockWorkPackage2 = { export const mockWorkPackageWithSemanticId = { id: 789, - displayId: "DWPS-1", + displayId: 'DWPS-1', subject: 'Semantic ID work package', _links: { self: { href: '/api/v3/work_packages/789' }, diff --git a/test/tsconfig.json b/test/tsconfig.json new file mode 100644 index 0000000..218bd73 --- /dev/null +++ b/test/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../tsconfig.app.json", + "compilerOptions": { + "rootDir": ".", + "noEmit": true, + "types": ["vitest/globals", "vite/client"], + "allowImportingTsExtensions": true + }, + "include": ["."] +} diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 0000000..0bd60ee --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.app.json", + "compilerOptions": { + "rootDir": ".", + "noEmit": true, + "types": ["vitest/globals", "vite/client"], + "allowImportingTsExtensions": true + }, + "include": [ + "test", + "global.d.ts", + "vite.config.ts", + "vitest.config.ts", + "vitest.browser.config.ts" + ] +} diff --git a/vite.config.ts b/vite.config.ts index 301d772..7a0ff91 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,6 +1,6 @@ -import { defineConfig } from "vite"; -import react from "@vitejs/plugin-react"; -import path from "node:path"; +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import path from 'node:path'; const __dirname = path.resolve(); @@ -9,20 +9,20 @@ export default defineConfig({ plugins: [react()], build: { lib: { - entry: path.resolve(__dirname, "lib/index.ts"), - name: "OpBlocknoteExtensions", - formats: ["es", "cjs"], + entry: path.resolve(__dirname, 'lib/index.ts'), + name: 'OpBlocknoteExtensions', + formats: ['es', 'cjs'], fileName: (format) => `op-blocknote-extensions.${format}.js`, }, rollupOptions: { // Externalize deps that shouldn't be bundled external: [ - "react", - "react-dom", - "@blocknote/core", - "@blocknote/react", - "@blocknote/mantine", - "yjs", + 'react', + 'react-dom', + '@blocknote/core', + '@blocknote/react', + '@blocknote/mantine', + 'yjs', ], }, }, diff --git a/vitest.browser.config.ts b/vitest.browser.config.ts index f305994..faa3b9e 100644 --- a/vitest.browser.config.ts +++ b/vitest.browser.config.ts @@ -1,6 +1,6 @@ -import { defineConfig } from 'vitest/config' -import react from '@vitejs/plugin-react' -import { playwright } from '@vitest/browser-playwright' +import { defineConfig } from 'vitest/config'; +import react from '@vitejs/plugin-react'; +import { playwright } from '@vitest/browser-playwright'; export default defineConfig({ plugins: [react()], @@ -14,4 +14,4 @@ export default defineConfig({ }, include: ['test/**/*.browser.test.tsx'], }, -}) \ No newline at end of file +}); \ No newline at end of file diff --git a/vitest.config.ts b/vitest.config.ts index 68aa902..0cf572f 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,5 +1,5 @@ -import { defineConfig } from 'vitest/config' -import react from '@vitejs/plugin-react' +import { defineConfig } from 'vitest/config'; +import react from '@vitejs/plugin-react'; export default defineConfig({ plugins: [react()], @@ -7,4 +7,4 @@ export default defineConfig({ include: ['test/**/*.test.ts', 'test/**/*.test.tsx'], exclude: ['test/**/*.browser.test.tsx'], }, -}) \ No newline at end of file +}); \ No newline at end of file