fix(ui)+test: QA UI bugs (#216 #218) + test coverage (#206 #204 #192) #230

Merged
vvzvlad merged 5 commits from fix/qa-ui-bugs-216-218 into develop 2026-06-27 22:50:19 +03:00
7 changed files with 83 additions and 21 deletions
Showing only changes of commit 40d1cdfc77 - Show all commits

View File

@@ -35,9 +35,17 @@ export interface ISharedItem extends IShare {
};
}
export interface ISharedPage extends IShare {
page: IPage;
share: IShare & {
// The `/shares/page-info` (anonymous) response. Mirrors the server-side
// PublicSharePayload allowlist (#218): the server trims `page`/`share` to these
// fields exactly, so the client type must not over-declare internal metadata it
// will never receive. Keep this in sync with share-public-payload.ts.
export interface ISharedPage {
page: Pick<IPage, "id" | "slugId" | "title" | "icon" | "content">;
share: {
id: string;
key: string;
includeSubPages: boolean;
searchIndexing: boolean;
level: number;
sharedPage: { id: string; slugId: string; title: string; icon: string };
};

View File

@@ -253,10 +253,7 @@ export class ShareService {
workspaceId: string,
): Promise<boolean> {
// Fast path: the request names the page's own resolved share.
if (
requestedShareId === resolvedShare.id ||
requestedShareId.toLowerCase() === resolvedShare.key?.toLowerCase()
) {
if (this.shareIdGrantsAccess(requestedShareId, resolvedShare)) {
return true;
}
@@ -270,6 +267,23 @@ export class ShareService {
return !!ancestor;
}
/**
* Does the requested share id/key directly name `resolvedShare` — by id, or
* by key (case-insensitive)? This is the "names the page's OWN share" half of
* the access concept; ancestor includeSubPages shares are matched separately.
* Intentionally narrower than `resolveReadableSharePage`'s id-only gate, which
* keeps its own contract for the callers that pass a shareId there.
*/
private shareIdGrantsAccess(
requestedShareId: string,
resolvedShare: { id: string; key?: string | null },
): boolean {
return (
requestedShareId === resolvedShare.id ||
requestedShareId.toLowerCase() === resolvedShare.key?.toLowerCase()
);
}
async getShareForPage(pageId: string, workspaceId: string) {
// here we try to check if a page was shared directly or if it inherits the share from its closest shared ancestor
const share = await this.db

View File

@@ -159,6 +159,14 @@ describe('getInternalLinkPageName', () => {
expect(getInternalLinkPageName('docs/v1.2.md')).toBe('v1.2');
});
it('documents current behavior: a leading-dot name collapses to empty text', () => {
// ".gitignore" -> base ".gitignore", parts ["", "gitignore"]: the leading
// dot is treated as a (empty) name + extension, so the name drops to "".
// Same bug class as #204, but unreachable via the sole caller (page titles
// never start with a dot), so we only pin the behavior — not fix it.
expect(getInternalLinkPageName('.gitignore')).toBe('');
});
it('falls back to the raw name without throwing on malformed encoding', () => {
// "%E0%A4" is an incomplete escape; decodeURIComponent throws and the
// helper returns the raw (still-encoded) name.

View File

@@ -0,0 +1,33 @@
/**
* Shared pieces for the two callout tokenizers — `callout.marked.ts` (the
* `:::type` fenced form) and `github-callout.marked.ts` (the `> [!type]` GitHub
* alert form). Both emit the SAME callout node, so the banner type dictionary
* and the HTML renderer live here once instead of drifting apart in two files.
* The tokenizers themselves stay separate (different syntaxes / source matching).
*/
/** The four callout banner types the editor schema supports. */
export const CALLOUT_TYPES = ['info', 'success', 'warning', 'danger'] as const;
export type CalloutType = (typeof CALLOUT_TYPES)[number];
/**
* Coerce an arbitrary type name onto a supported banner type, defaulting to
* `info` for anything unrecognized (the shared fallback both tokenizers use).
*/
export function normalizeCalloutType(type: string): CalloutType {
return (CALLOUT_TYPES as readonly string[]).includes(type)
? (type as CalloutType)
: 'info';
}
/**
* Render a callout node to the editor's HTML shape. `body` is the already
* markdown-parsed inner content (marked may hand back a string synchronously).
*/
export function renderCalloutHtml(
type: string,
body: string | Promise<string>,
): string {
return `<div data-type="callout" data-callout-type="${type}">${body}</div>`;
}

View File

@@ -1,4 +1,5 @@
import { Token, marked } from 'marked';
import { normalizeCalloutType, renderCalloutHtml } from './callout-common.marked';
interface CalloutToken {
type: 'callout';
@@ -17,16 +18,10 @@ export const calloutExtension = {
const rule = /^:::([a-zA-Z0-9]+)\s+([\s\S]+?):::/;
const match = rule.exec(src);
const validCalloutTypes = ['info', 'success', 'warning', 'danger'];
if (match) {
let type = match[1];
if (!validCalloutTypes.includes(type)) {
type = 'info';
}
return {
type: 'callout',
calloutType: type,
calloutType: normalizeCalloutType(match[1]),
raw: match[0],
text: match[2].trim(),
};
@@ -34,8 +29,9 @@ export const calloutExtension = {
},
renderer(token: Token) {
const calloutToken = token as CalloutToken;
const body = marked.parse(calloutToken.text);
return `<div data-type="callout" data-callout-type="${calloutToken.calloutType}">${body}</div>`;
return renderCalloutHtml(
calloutToken.calloutType,
marked.parse(calloutToken.text),
);
},
};

View File

@@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest";
import { markdownToHtml } from "./marked.utils";
/**
* Regression for issue #218: pasting a GitHub-style `> [!type]` alert produced a
* Regression for issue #192: pasting a GitHub-style `> [!type]` alert produced a
* literal `<blockquote>` containing `[!info]` instead of a callout node, because
* only the `:::type` form was tokenized. The editor paste path runs the same
* `markdownToHtml`, so these assertions pin the conversion at the source.

View File

@@ -1,4 +1,5 @@
import { Token, marked } from 'marked';
import { renderCalloutHtml } from './callout-common.marked';
interface GithubCalloutToken {
type: 'githubCallout';
@@ -36,7 +37,7 @@ const GITHUB_ALERT_TYPE_MAP: Record<string, string> = {
* Without this, the default blockquote tokenizer wins and the marker renders as
* a literal `[!info]` inside a `<blockquote>`. The editor's paste path runs the
* same `markdownToHtml`, so registering this here also fixes pasting the syntax
* into the editor (issue #218), not just markdown import.
* into the editor (issue #192), not just markdown import.
*/
export const githubCalloutExtension = {
name: 'githubCallout',
@@ -72,7 +73,9 @@ export const githubCalloutExtension = {
},
renderer(token: Token) {
const calloutToken = token as GithubCalloutToken;
const body = marked.parse(calloutToken.text);
return `<div data-type="callout" data-callout-type="${calloutToken.calloutType}">${body}</div>`;
return renderCalloutHtml(
calloutToken.calloutType,
marked.parse(calloutToken.text),
);
},
};