Skip to content
Open
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
4 changes: 2 additions & 2 deletions extensions/deck/js/src/forum/columns/DeckColumnType.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,8 @@ export interface DeckColumnField {
input?: (attrs: { id: string; value: string; onchange: (value: string) => void }) => Mithril.Children;
/** Converts what the member typed into the stored value; return null to reject it. */
parse?: (value: string) => string | number | null;
/** Shown when `parse` rejects the value. */
invalidText?: Mithril.Children;
/** Shown when `parse` rejects the value; a function is given what was typed. */
invalidText?: Mithril.Children | ((value: string) => Mithril.Children);
}

/**
Expand Down
19 changes: 15 additions & 4 deletions extensions/deck/js/src/forum/columns/deckColumnTypes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,12 +57,17 @@ const memberField = (): DeckColumnField => ({
const memberName = (config: DeckColumnConfig): string =>
app.store.getById<User>('users', String(config.params.userId))?.displayName() ?? String(config.params.name);

const onlyGambits = (query: string): string | null => {
const leftover = app.search.gambits.match('discussions', query, () => {});
/** The words of a query that no discussion filter takes: plain words, or another resource's filters such as `group:`. */
const unknownParts = (query: string): string[] => {
const matched = new Set<string>();

return query && !leftover ? query : null;
app.search.gambits.match('discussions', query, (gambit, matches, negate, bit) => matched.add(bit));

return query.split(/\s+/).filter((bit) => bit && !matched.has(bit));
};

const onlyGambits = (query: string): string | null => (query && !unknownParts(query).length ? query : null);

export function registerDefaultColumnTypes(): void {
deckColumnTypes.add(
'all',
Expand Down Expand Up @@ -213,7 +218,13 @@ export function registerDefaultColumnTypes(): void {
// Every extension's registered discussion gambits, as in the search modal.
gambits: 'discussions',
parse: onlyGambits,
invalidText: app.translator.trans('flarum-deck.forum.add_column.filter_invalid_message'),
invalidText: (query: string) => {
const unknown = unknownParts(query);

return unknown.length
? app.translator.trans('flarum-deck.forum.add_column.filter_unknown_message', { count: unknown.length, parts: unknown.join(' ') })
: app.translator.trans('flarum-deck.forum.add_column.filter_invalid_message');
},
},
],
createSource: (config) => new DiscussionListSource({ filter: gambitFilters(String(config.params.query)) }),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,9 @@ export default class AddColumnModal<CustomAttrs extends IAddColumnModalAttrs = I
const value = field.parse ? field.parse(raw) : raw || null;

if (value === null || value === '') {
this.error = field.invalidText ?? app.translator.trans('flarum-deck.forum.add_column.invalid_value_message', { field: field.label });
const invalid = typeof field.invalidText === 'function' ? field.invalidText(raw) : field.invalidText;

this.error = invalid ?? app.translator.trans('flarum-deck.forum.add_column.invalid_value_message', { field: field.label });
return;
}

Expand Down
14 changes: 10 additions & 4 deletions extensions/deck/js/src/forum/components/DeckFilterModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ import DeckPickerModal, { type IDeckPickerModalAttrs } from './DeckPickerModal';
export interface IDeckFilterModalAttrs extends IDeckPickerModalAttrs {
/** Whether a query can be used, e.g. that it's filters only. */
accept: (query: string) => boolean;
invalidText?: Mithril.Children;
/** Shown when `accept` turns the query away; a function is given the query. */
invalidText?: Mithril.Children | ((query: string) => Mithril.Children);
onapply: (query: string) => void;
}

Expand All @@ -21,6 +22,8 @@ export interface IDeckFilterModalAttrs extends IDeckPickerModalAttrs {
*/
export default class DeckFilterModal<CustomAttrs extends IDeckFilterModalAttrs = IDeckFilterModalAttrs> extends DeckPickerModal<CustomAttrs> {
protected error: Mithril.Children = null;
/** The query the error is about; it no longer applies once that changes. */
protected rejected: string | null = null;

/**
* @param resource Whose filters to offer, e.g. 'discussions'.
Expand All @@ -31,7 +34,7 @@ export default class DeckFilterModal<CustomAttrs extends IDeckFilterModalAttrs =
title: Mithril.Children;
value: string;
accept: (query: string) => boolean;
invalidText?: Mithril.Children;
invalidText?: IDeckFilterModalAttrs['invalidText'];
onapply: (query: string) => void;
}
): void {
Expand All @@ -51,7 +54,7 @@ export default class DeckFilterModal<CustomAttrs extends IDeckFilterModalAttrs =
return [
super.content(),
<div className="Modal-footer DeckFilterModal-footer">
{this.error && <p className="DeckFilterModal-error">{this.error}</p>}
{this.error && this.query().trim() === this.rejected && <p className="DeckFilterModal-error">{this.error}</p>}
<Button className="Button Button--primary" type="button" onclick={() => this.apply()}>
{app.translator.trans('flarum-deck.forum.add_column.use_filters_button')}
</Button>
Expand Down Expand Up @@ -87,7 +90,10 @@ export default class DeckFilterModal<CustomAttrs extends IDeckFilterModalAttrs =
const query = this.query().trim();

if (!this.attrs.accept(query)) {
this.error = this.attrs.invalidText ?? null;
const invalidText = this.attrs.invalidText;

this.error = (typeof invalidText === 'function' ? invalidText(query) : invalidText) ?? null;
this.rejected = query;
m.redraw();
return;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import app from 'flarum/forum/app';
import extractText from 'flarum/common/utils/extractText';
import deckColumnTypes, { registerDefaultColumnTypes } from '../../../../src/forum/columns/deckColumnTypes';
import type { DeckColumnField } from '../../../../src/forum/columns/DeckColumnType';
import { boot, loadDeckTranslations } from '../helpers';

beforeAll(() => {
boot();
loadDeckTranslations();
if (!deckColumnTypes.has('filter')) registerDefaultColumnTypes();
});

const queryField = (): DeckColumnField => deckColumnTypes.get('filter').fields!().find((field) => field.key === 'query')!;

const invalidText = (value: string): string => {
const text = queryField().invalidText;

return extractText(typeof text === 'function' ? text(value) : text);
};

describe('the custom filter field', () => {
it('takes several filters, negated ones too', () => {
expect(queryField().parse!('is:unread -author:normal')).toBe('is:unread -author:normal');
});

// A pasted query can hold tabs or runs of spaces between its filters.
it('takes filters separated by any whitespace', () => {
expect(queryField().parse!('is:unread \t -author:normal')).not.toBeNull();
});

it('refuses plain words and filters that aren’t for discussions', () => {
expect(queryField().parse!('group:mods is:unread')).toBeNull();
expect(queryField().parse!('hello is:unread')).toBeNull();
});

// group: is a filter for members, so "plain words aren't supported" left
// people looking for words they hadn't typed.
it('names what it couldn’t use', () => {
expect(invalidText('group:mods is:unread -author:normal')).toMatch(/^group:mods isn't a filter for discussions\./);
});

// That sentence is what sent people looking for words they hadn't typed.
it('doesn’t blame plain words when it names the parts', () => {
expect(invalidText('group:mods is:unread')).not.toContain('Plain words');
});

it('names every part it couldn’t use', () => {
expect(invalidText('group:mods is:unread hello')).toMatch(/^group:mods hello aren't filters for discussions\./);
});

it('asks for filters when nothing was typed', () => {
expect(invalidText('')).toBe(extractText(app.translator.trans('flarum-deck.forum.add_column.filter_invalid_message')));
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,40 @@ describe('adding a custom filter column', () => {
});
});

describe('a value the column type turns away', () => {
beforeAll(() => {
if (deckColumnTypes.has('test-code')) return;

deckColumnTypes.add('test-code', {
icon: 'fas fa-vial',
label: () => 'Code',
title: () => 'Code',
isAvailable: () => true,
fields: () => [{ key: 'code', label: 'Code', parse: (value: string) => (value === 'ok' ? value : null), invalidText: (value: string) => `${value} won't do` }],
createSource: () => ({}) as any,
});
});

it('shows what the type says about what was typed', () => {
setLayout([]);

m.mount(root, {
view: () => m(AddColumnModal, { state: new ModalManagerState(), animateShow: () => {}, animateHide: () => {}, deck: new DeckState() }),
});

[...root.querySelectorAll<HTMLButtonElement>('.AddColumnModal-type')].find((button) => button.textContent?.includes('Code'))!.click();
m.redraw.sync();

const input = root.querySelector<HTMLInputElement>('#AddColumnModal-code')!;
input.value = 'nope';
input.dispatchEvent(new Event('input', { bubbles: true }));
root.querySelector<HTMLFormElement>('form')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
m.redraw.sync();

expect(root.querySelector('.AddColumnModal-error')?.textContent).toBe("nope won't do");
});
});

describe('what is already in the deck', () => {
function mount(deck: DeckState) {
m.mount(root, {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ class FakeSource {
/** Filters only, as the custom filter column wants. */
const filtersOnly = (query: string) => !!query && !app.search.gambits.match('discussions', query, () => {});

function open(query: string) {
function open(query: string, invalidText: any = 'Filters only, please.') {
const onapply = jest.fn();
const animateHide = jest.fn();
const searchState = new SearchState();
Expand All @@ -73,7 +73,7 @@ function open(query: string) {
onchange: () => {},
onpick: () => {},
accept: filtersOnly,
invalidText: 'Filters only, please.',
invalidText,
onapply,
}),
});
Expand Down Expand Up @@ -104,6 +104,30 @@ describe('DeckFilterModal', () => {
expect(root.querySelector('.DeckFilterModal-error')?.textContent).toBe('Filters only, please.');
});

it('can say what was wrong with the query written', () => {
open('group:mods is:unread', (query: string) => `Can't use ${query}`);

useButton().click();
m.redraw.sync();

expect(root.querySelector('.DeckFilterModal-error')?.textContent).toBe("Can't use group:mods is:unread");
});

// It names what was wrong with that query, which no longer holds once edited.
it('stops showing the error once the query changes', () => {
open('group:mods is:unread', (query: string) => `Can't use ${query}`);

useButton().click();
m.redraw.sync();

const input = root.querySelector<HTMLInputElement>('.SearchModal-input')!;
input.value = 'is:unread';
input.dispatchEvent(new Event('input', { bubbles: true }));
m.redraw.sync();

expect(root.querySelector('.DeckFilterModal-error')).toBeNull();
});

it('uses the filters on Enter', () => {
const { onapply } = open('is:unread');

Expand Down
1 change: 1 addition & 0 deletions extensions/deck/locale/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ flarum-deck:
in_deck_badge: In your deck
in_deck_count_badge: "In your deck ×{count}"
filter_placeholder: e.g. tag:support is:unread
filter_unknown_message: "{count, plural, one {{parts} isn't a filter for discussions.} other {{parts} aren't filters for discussions.}} Use filters such as tag:support or is:unread."
invalid_value_message: "Please enter a valid value for {field}."
pick_a_result_message: "Choose a result for {field} from the suggestions."
submit_button: Add column
Expand Down
Loading