Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions bamboo-specs/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,6 @@
--build-arg "BASE_IMAGE=${bamboo_dockerFrontend}" \
--build-arg "CACHE_BUSTER=${bamboo_cacheBuster}" \
--build-arg "CLIENT_DIR=${bamboo_clientDir}" \
--output '.' \
--progress 'plain' \
--target 'tester' \
-f ./docker/frontend.Dockerfile \
Expand Down Expand Up @@ -222,7 +221,6 @@
--build-arg "BASE_IMAGE=${bamboo_dockerFrontend}" \
--build-arg "CACHE_BUSTER=${bamboo_cacheBuster}" \
--build-arg "CLIENT_DIR=${bamboo_clientDir}" \
--output '.' \
--progress 'plain' \
--target 'e2etester' \
-f ./docker/frontend.Dockerfile \
Expand Down
59 changes: 53 additions & 6 deletions client_v2/src/__tests__/clientForm/validators.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
import {
validateIdentifier,
validateUpstreams,
validateBootstrapDns,
validateRequiredValue,
validateIpv4,
validatePort,
Expand Down Expand Up @@ -503,24 +504,70 @@ describe('validateRewriteNotSame', () => {
});
});

describe('validateUpstreams with bang comments', () => {
it('accepts lines starting with !', () => {
expect(validateUpstreams('! comment line\n8.8.8.8')).toBeUndefined();
describe('validateUpstreams with bang prefix', () => {
it('rejects lines starting with ! as invalid upstreams', () => {
expect(validateUpstreams('! comment line\n8.8.8.8')).toBeTruthy();
});

it('accepts only comment lines (!)', () => {
expect(validateUpstreams('! first\n! second')).toBeUndefined();
it('rejects only ! lines as invalid', () => {
expect(validateUpstreams('! first\n! second')).toBeTruthy();
});

it('rejects !-prefixed URLs that would otherwise pass address check', () => {
expect(validateUpstreams('! https://dns10.quad9.net/dns-query')).toBeTruthy();
});

it('rejects !-prefixed upstream with dot in comment', () => {
expect(validateUpstreams('! dns.example.com:53')).toBeTruthy();
});

it('still accepts # comments', () => {
expect(validateUpstreams('# comment\n8.8.8.8')).toBeUndefined();
});

it('rejects invalid non-comment lines alongside comments', () => {
it('rejects ! and invalid non-comment lines', () => {
expect(validateUpstreams('! good\ninvalidline')).toBeTruthy();
});
});

describe('validateBootstrapDns', () => {
it('returns undefined for empty value', () => {
expect(validateBootstrapDns('')).toBeUndefined();
});

it('returns undefined for valid IP', () => {
expect(validateBootstrapDns('8.8.8.8')).toBeUndefined();
});

it('returns undefined for valid upstream URL', () => {
expect(validateBootstrapDns('https://dns.example.com/dns-query')).toBeUndefined();
});

it('rejects # comment lines (no comment support)', () => {
expect(validateBootstrapDns('# comment\n8.8.8.8')).toBeTruthy();
});

it('rejects #-prefixed URLs that would otherwise pass address check', () => {
expect(validateBootstrapDns('# https://dns.example.com')).toBeTruthy();
});

it('rejects only # lines', () => {
expect(validateBootstrapDns('# first\n# second')).toBeTruthy();
});

it('rejects !-prefixed lines', () => {
expect(validateBootstrapDns('! https://dns.example.com')).toBeTruthy();
});

it('rejects lines without dot or colon', () => {
expect(validateBootstrapDns('not-a-server')).toBe('Invalid format');
});

it('returns line-numbered error for mixed valid and # comment', () => {
expect(validateBootstrapDns('8.8.8.8\n# comment')).toBe('Invalid format on line 2');
});
});

describe('validateLeaseTime', () => {
it('accepts 1 (minimum)', () => {
expect(validateLeaseTime(1)).toBeUndefined();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,14 @@ describe('validateDomainsPerLine', () => {
expect(validateDomainsPerLine('# this is a comment')).toBeUndefined();
});

it('rejects !-prefixed filter rule that would otherwise pass dot check', () => {
expect(validateDomainsPerLine('! ||example.org^')).toBeTruthy();
});

it('rejects only ! lines as invalid', () => {
expect(validateDomainsPerLine('! first\n! second')).toBeTruthy();
});

it('returns undefined for mixed valid lines with comments', () => {
expect(
validateDomainsPerLine('# comment\nexample.org\n||ads.example.org^'),
Expand Down
92 changes: 70 additions & 22 deletions client_v2/src/common/controls/Textarea/Textarea.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,47 @@
import { type JSX, Show } from 'solid-js';
import { type JSX, Show, createSignal, createEffect } from 'solid-js';
import cn from 'clsx';

import s from './styles.module.pcss';
import { TextareaHighlight } from './TextareaHighlight';

import type { CommentLineToken } from 'panel/helpers/constants';

type TextareaChangeEvent = Event & {
currentTarget: HTMLTextAreaElement;
target: HTMLTextAreaElement;
};

type Props = Omit<JSX.TextareaHTMLAttributes<HTMLTextAreaElement>, 'onChange' | 'onBlur'> & {
type Props = Omit<
JSX.TextareaHTMLAttributes<HTMLTextAreaElement>,
'onChange' | 'onBlur' | 'onInput' | 'onScroll'
> & {
label?: JSX.Element;
size?: 'small' | 'medium' | 'large';
errorMessage?: string;
highlightComments?: boolean;
commentPrefixes?: readonly CommentLineToken[];
ref?: HTMLTextAreaElement | ((el: HTMLTextAreaElement) => void);
onChange?: (event: TextareaChangeEvent) => void;
onInput?: (event: TextareaChangeEvent) => void;
onBlur?: (event: FocusEvent) => void;
onScroll?: (
event: Event & { currentTarget: HTMLTextAreaElement; target: HTMLTextAreaElement },
) => void;
};

export const Textarea = (props: Props) => {
const [scrollTop, setScrollTop] = createSignal(0);
const [currentValue, setCurrentValue] = createSignal('');

// Sync from external prop changes (e.g. dialog open/close resets);
// also handles initial value on mount. Runs before browser paint so
// there is no visible flicker from the empty initial signal.
createEffect(() => {
setCurrentValue(props.value as string);
});

const highlightEnabled = () => !!props.highlightComments;

const setRef = (el: HTMLTextAreaElement) => {
if (typeof props.ref === 'function') {
props.ref(el);
Expand All @@ -28,37 +52,61 @@ export const Textarea = (props: Props) => {
props.onChange?.(e);
};

const handleInput = (e: TextareaChangeEvent) => {
setCurrentValue(e.target.value);
props.onInput?.(e);
};

const handleBlur = (e: FocusEvent) => {
props.onBlur?.(e);
};

const handleScroll = (
e: Event & { currentTarget: HTMLTextAreaElement; target: HTMLTextAreaElement },
) => {
setScrollTop(e.target.scrollTop);
props.onScroll?.(e);
};

return (
<div class={s.textareaWrapper}>
<Show when={props.label}>
<label class={s.label} for={props.id}>
{props.label}
</label>
</Show>
<textarea
class={cn(
s.textarea,
props.size && s[props.size],
{ [s.error]: !!props.errorMessage },
props.class,
)}
id={props.id}
name={props.name}
placeholder={props.placeholder}
value={props.value as string}
cols={props.cols}
rows={props.rows}
onChange={handleChange}
onBlur={handleBlur}
wrap={props.wrap}
maxLength={props.maxLength}
disabled={props.disabled}
ref={(el) => setRef(el)}
/>
<div class={s.textareaContainer}>
<textarea
class={cn(
s.textarea,
props.size && s[props.size],
{ [s.error]: !!props.errorMessage },
{ [s.transparentText]: highlightEnabled() },
props.class,
)}
id={props.id}
name={props.name}
placeholder={props.placeholder}
value={props.value as string}
cols={props.cols}
rows={props.rows}
onChange={handleChange}
onInput={handleInput}
onBlur={handleBlur}
onScroll={handleScroll}
wrap={props.wrap}
maxLength={props.maxLength}
disabled={props.disabled}
ref={(el) => setRef(el)}
/>
<Show when={highlightEnabled()}>
<TextareaHighlight
value={currentValue}
scrollTop={scrollTop}
commentPrefixes={props.commentPrefixes}
/>
</Show>
</div>
<Show when={props.errorMessage}>
<div class={s.errorMessage}>{props.errorMessage}</div>
</Show>
Expand Down
58 changes: 58 additions & 0 deletions client_v2/src/common/controls/Textarea/TextareaHighlight.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { For, type Accessor, createEffect, createMemo } from 'solid-js';
import cn from 'clsx';

import { COMMENT_LINE_DEFAULT_TOKEN, type CommentLineToken } from 'panel/helpers/constants';

import s from './styles.module.pcss';

type Props = {
value: Accessor<string>;
scrollTop: Accessor<number>;
class?: string;
/** Comment line prefixes to highlight. Defaults to {@link COMMENT_LINE_DEFAULT_TOKEN}. */
commentPrefixes?: readonly CommentLineToken[];
};

export const TextareaHighlight = (props: Props) => {
const lines = () => (props.value() || '').split('\n');
let ref: HTMLDivElement | undefined;

const prefixes = createMemo(() => props.commentPrefixes || [COMMENT_LINE_DEFAULT_TOKEN]);

const isComment = (line: string) => {
const trimmed = line.trimStart();
return prefixes().some((p) => trimmed.startsWith(p));
};

createEffect(() => {
const el = ref;
if (el) {
el.scrollTop = props.scrollTop();
}
});

return (
<div
ref={(el) => {
ref = el;
}}
class={cn(s.overlay, props.class)}
aria-hidden="true"
>
<For each={lines()}>
{(line, index) => (
<>
{index() > 0 && '\n'}
<span
class={cn({
[s.commentLine]: isComment(line),
})}
>
{line || ' '}
</span>
</>
)}
</For>
</div>
);
};
43 changes: 43 additions & 0 deletions client_v2/src/common/controls/Textarea/styles.module.pcss
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
.textarea {
font-size: 16px;
line-height: 1.3;
width: 100%;
max-width: 100%;
padding: 16px;
border: 1px solid var(--default-item-divider);
Expand Down Expand Up @@ -61,6 +62,48 @@
color: var(--default-description-text);
}

.textareaContainer {
position: relative;
font-size: 0;
}

.overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
overflow-y: auto;
pointer-events: none;
white-space: pre-wrap;
word-wrap: break-word;
padding: 16px;
border: 1px solid transparent;
box-sizing: border-box;
font-size: 16px;
line-height: 1.3;
max-width: 100%;
border-radius: 8px;
}

.commentLine {
color: var(--default-additional-link);
}

.transparentText {
color: transparent;
caret-color: var(--default-main-text);
scrollbar-width: none;

&::-webkit-scrollbar {
display: none;
}

&::placeholder {
color: var(--default-placeholder);
}
}

.errorMessage {
margin-top: 8px;
font-size: 14px;
Expand Down
4 changes: 2 additions & 2 deletions client_v2/src/common/ui/Menu/Menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,8 @@ export const Menu = (props: Props) => {
},
{
label: intl.getMessage('user_rules_title'),
path: Paths.UserRules,
routePath: RoutePath.UserRules,
path: Paths.CustomRules,
routePath: RoutePath.CustomRules,
},
]}
isActive={isActive}
Expand Down
Loading
Loading