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
31 changes: 22 additions & 9 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1907,19 +1907,21 @@ export class StreamChat {
}

const { predefined_filter, filter_values, sort_values, ...restOptions } = options;
const normalizedSort = normalizeQuerySort(sort);

// Build payload based on whether we're using a predefined filter or traditional filters
const payload = predefined_filter
? {
predefined_filter,
filter_values,
sort_values,
sort: normalizedSort,
...defaultOptions,
...restOptions,
}
: {
filter_conditions: filterConditions,
sort: normalizeQuerySort(sort),
sort: normalizedSort,
...defaultOptions,
...restOptions,
};
Expand All @@ -1929,6 +1931,9 @@ export class StreamChat {
payload,
);

// FIXME: In the next major release, return the full QueryChannelsAPIResponse
// instead of only `data.channels` so top-level metadata such as
// `predefined_filter` is not lost.
Comment thread
isekovanic marked this conversation as resolved.
return data.channels;
}

Expand Down Expand Up @@ -4981,9 +4986,11 @@ export class StreamChat {
*
* @return {Promise<PredefinedFilterResponse>} The created predefined filter
*/
async createPredefinedFilter(options: CreatePredefinedFilterOptions) {
async createPredefinedFilter<
F extends Record<string, unknown> = Record<string, unknown>,
>(options: CreatePredefinedFilterOptions<F>) {
this.validateServerSideAuth();
return await this.post<PredefinedFilterResponse>(
return await this.post<PredefinedFilterResponse<F>>(
`${this.baseURL}/predefined_filters`,
options,
);
Expand All @@ -4996,9 +5003,11 @@ export class StreamChat {
*
* @return {Promise<PredefinedFilterResponse>} The predefined filter
*/
async getPredefinedFilter(name: string) {
async getPredefinedFilter<F extends Record<string, unknown> = Record<string, unknown>>(
name: string,
) {
this.validateServerSideAuth();
return await this.get<PredefinedFilterResponse>(
return await this.get<PredefinedFilterResponse<F>>(
`${this.baseURL}/predefined_filters/${encodeURIComponent(name)}`,
);
}
Expand All @@ -5011,9 +5020,11 @@ export class StreamChat {
*
* @return {Promise<PredefinedFilterResponse>} The updated predefined filter
*/
async updatePredefinedFilter(name: string, options: UpdatePredefinedFilterOptions) {
async updatePredefinedFilter<
F extends Record<string, unknown> = Record<string, unknown>,
>(name: string, options: UpdatePredefinedFilterOptions<F>) {
this.validateServerSideAuth();
return await this.put<PredefinedFilterResponse>(
return await this.put<PredefinedFilterResponse<F>>(
`${this.baseURL}/predefined_filters/${encodeURIComponent(name)}`,
options,
);
Expand All @@ -5040,10 +5051,12 @@ export class StreamChat {
*
* @return {Promise<ListPredefinedFiltersResponse>} The list of predefined filters
*/
async listPredefinedFilters(options: ListPredefinedFiltersOptions = {}) {
async listPredefinedFilters<
F extends Record<string, unknown> = Record<string, unknown>,
>(options: ListPredefinedFiltersOptions = {}) {
this.validateServerSideAuth();
const { sort, ...paginationOptions } = options;
return await this.get<ListPredefinedFiltersResponse>(
return await this.get<ListPredefinedFiltersResponse<F>>(
`${this.baseURL}/predefined_filters`,
{
...paginationOptions,
Expand Down
148 changes: 133 additions & 15 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1060,13 +1060,29 @@ export type ChannelOptions = {
user_id?: string;
watch?: boolean;
/**
* Name of a predefined filter to use instead of filter_conditions.
* When provided, filter_conditions and sort parameters are ignored.
* Name of a predefined filter to use instead of sending raw
* `filter_conditions`.
*
* The backend resolves the filter template by name and interpolates it using
* `filter_values`.
*
* A regular `sort` can still be passed to `queryChannels()`, but backend
* precedence rules apply:
*
* - if the predefined filter has its own stored sort template, that stored
* sort takes precedence and the request `sort` is ignored
* - if the predefined filter does not define a sort template, the request
* `sort` can still be used
*/
predefined_filter?: string;
/**
* Values to interpolate into the predefined filter template placeholders.
* Only used when predefined_filter is provided.
* Values used to interpolate placeholders inside the predefined filter's
* `filter` template.
*
* Example: a template value like `{{user_id}}` can be resolved with
* `{ user_id: 'alice' }`.
*
* Only used when `predefined_filter` is provided.
*/
filter_values?: Record<string, unknown>;
/**
Expand Down Expand Up @@ -4755,38 +4771,129 @@ export type UpdateChannelsBatchResponse = {
export type PredefinedFilterOperation = 'QueryChannels';

export type PredefinedFilterSortParam = {
/**
* Field name to sort by.
*
* This may be a literal field name such as `created_at`, or a placeholder
* template such as `{{sort_field}}` that will be interpolated server-side.
*/
field: string;
/**
* Sort direction. `1` means ascending and `-1` means descending.
*
* The backend defaults this to `1` when omitted.
*/
direction?: AscDesc;
/**
* Optional server-side hint describing how the sort field value should be
* interpreted.
*
* This is mainly relevant for predefined-filter sort templates and is not
* part of the regular `queryChannels()` sort shape. Omitting it uses the
* backend default string behavior. Known backend values include:
*
* - `number`: cast custom-field values to numeric before sorting
* - `boolean`: cast custom-field values to boolean before sorting
*
* Other values are backend-defined. In most cases this should be omitted
* unless you are sorting by a custom field whose stored JSON value is not
* string-like.
*/
type?: string;
};

export type PredefinedFilter = {
/**
* Stored predefined filter definition as returned by the server.
*
* `F` represents the raw filter template shape. It defaults to a generic record
* because predefined filters are server-managed templates and may include
* placeholders or app-specific structures.
*/
export type PredefinedFilter<
F extends Record<string, unknown> = Record<string, unknown>,
> = {
/**
* Unique predefined filter name within the app.
*/
name: string;
/**
* Operation this predefined filter is valid for.
*/
operation: PredefinedFilterOperation;
filter: Record<string, unknown>;
/**
* Filter template stored on the server.
*
* This is not necessarily the fully interpolated runtime filter; placeholder
* values such as `{{user_id}}` may still be present.
*/
filter: F;
/**
* Server creation timestamp in ISO-8601 format.
*/
created_at: string;
/**
* Server update timestamp in ISO-8601 format.
*/
updated_at: string;
/**
* Optional human-readable description.
*/
description?: string;
/**
* Optional sort template stored with the predefined filter.
*/
sort?: PredefinedFilterSortParam[];
/**
* Query identifier generated by the backend for the filter/sort pattern.
*
* The exact value is backend-generated and primarily useful for correlating
* predefined filters with query analysis / query performance data.
*/
query_id?: number;
};

export type CreatePredefinedFilterOptions = {
export type CreatePredefinedFilterOptions<
F extends Record<string, unknown> = Record<string, unknown>,
> = {
/**
* Unique predefined filter name.
*/
name: string;
/**
* Operation this predefined filter will be used with.
*/
operation: PredefinedFilterOperation;
filter: Record<string, unknown>;
/**
* Filter template to store on the server.
*/
filter: F;
/**
* Optional human-readable description.
*/
description?: string;
/**
* Optional sort template stored with the predefined filter.
*/
sort?: PredefinedFilterSortParam[];
};

export type UpdatePredefinedFilterOptions = Omit<CreatePredefinedFilterOptions, 'name'>;
export type UpdatePredefinedFilterOptions<
F extends Record<string, unknown> = Record<string, unknown>,
> = Omit<CreatePredefinedFilterOptions<F>, 'name'>;

export type PredefinedFilterResponse = APIResponse & {
predefined_filter: PredefinedFilter;
export type PredefinedFilterResponse<
F extends Record<string, unknown> = Record<string, unknown>,
> = APIResponse & {
predefined_filter: PredefinedFilter<F>;
};

export type ListPredefinedFiltersResponse = APIResponse & {
predefined_filters: PredefinedFilter[];
/**
* Paginated response returned when listing predefined filters.
*/
export type ListPredefinedFiltersResponse<
F extends Record<string, unknown> = Record<string, unknown>,
> = APIResponse & {
predefined_filters: PredefinedFilter<F>[];
next?: string;
prev?: string;
};
Expand All @@ -4795,9 +4902,20 @@ export type ListPredefinedFiltersResponse = APIResponse & {
* Contains the interpolated filter and sort from a predefined filter.
* This is returned in the QueryChannels response when using a predefined filter.
*/
export type ParsedPredefinedFilterResponse = {
export type ParsedPredefinedFilterResponse<
F extends Record<string, unknown> = Record<string, unknown>,
> = {
/**
* Name of the predefined filter that was resolved.
*/
name: string;
filter: Record<string, unknown>;
/**
* Fully interpolated filter that the backend executed.
*/
filter: F;
/**
* Fully interpolated sort parameters resolved from the predefined filter.
*/
sort?: PredefinedFilterSortParam[];
};

Expand Down
34 changes: 34 additions & 0 deletions test/unit/predefined_filters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,40 @@ describe('Predefined Filters', () => {
);
});

it('should include traditional sort when using a predefined filter', async () => {
const mockResponse: QueryChannelsAPIResponse = {
duration: '0.01s',
channels: [],
};

const postSpy = vi.spyOn(client, 'post').mockResolvedValue(mockResponse);

await client.queryChannels({}, [{ last_message_at: -1 }, { created_at: 1 }], {
predefined_filter: 'user_messaging',
filter_values: { user_id: 'user123' },
limit: 20,
});

expect(postSpy).toHaveBeenCalledWith(
`${client.baseURL}/channels`,
expect.objectContaining({
predefined_filter: 'user_messaging',
filter_values: { user_id: 'user123' },
sort: [
{ field: 'last_message_at', direction: -1 },
{ field: 'created_at', direction: 1 },
],
limit: 20,
}),
);
expect(postSpy).toHaveBeenCalledWith(
`${client.baseURL}/channels`,
expect.not.objectContaining({
filter_conditions: expect.anything(),
}),
);
});

it('should use traditional filter_conditions when no predefined_filter is provided', async () => {
const mockResponse: QueryChannelsAPIResponse = {
duration: '0.01s',
Expand Down