diff --git a/src/lib/components/FilterForm.svelte b/src/lib/components/FilterForm.svelte index 652b39d4..2efde927 100644 --- a/src/lib/components/FilterForm.svelte +++ b/src/lib/components/FilterForm.svelte @@ -11,6 +11,11 @@ FilterLogic, CustomFilterEvent, } from "$lib/interfaces" + import { + replaceStateWithQuery, + activeTagsSet, + tagsForURLParam, + } from "$lib/utils" import Collapsible from "$lib/components/Collapsible.svelte" import TagWrapper from "$lib/components/TagWrapper.svelte" import Checkbox from "$lib/components/Checkbox.svelte" @@ -18,13 +23,22 @@ const dispatch = createEventDispatcher() let form: HTMLFormElement - export let filterOptions: FilterOption[] + export let filterData: { + filterOptions: FilterOption[] + filterLogicAnd: boolean + } = { + filterOptions: [], + filterLogicAnd: true, + } + + let filterOptions: FilterOption[] + $: ({ filterOptions } = filterData) export let showFilterLogic: boolean = true // Whether all the selected tags must match the resource (vs any of the selected tags) - export let filterLogicAnd: boolean = true + let filterLogicAndCtrl: boolean = filterData?.filterLogicAnd ?? true let filterLogic: FilterLogic - $: filterLogic = filterLogicAnd ? "and" : "or" + $: filterLogic = filterLogicAndCtrl ? "and" : "or" let isFilterDirty: boolean $: isFilterDirty = filterOptions.some( @@ -32,12 +46,29 @@ ) const resetFilters = () => { - filterOptions.forEach((option) => (option.active = false)) - dispatch("filter", { filterOptions, filterLogic }) + filterOptions.map((option) => (option.active = false)) + + replaceStateWithQuery({ + tags: "", + mode: "", + q: "", + }) + + const filterTags = activeTagsSet(filterOptions) + filterLogicAndCtrl = true + + dispatch("filter", { filterTags, filterLogic }) } const onSubmit = () => { - dispatch("filter", { filterOptions, filterLogic }) + const filterTags = activeTagsSet(filterOptions) + + replaceStateWithQuery({ + tags: tagsForURLParam(filterTags), + mode: filterLogic, + q: "", + }) + dispatch("filter", { filterTags, filterLogic }) } @@ -74,8 +105,8 @@ class="inline-flex items-center rounded-md cursor-pointer outline-2 outline-offset-1 focus-within:outline text-white border-2 border-green-700 dark:border-green-900/75" > import ButtonLinks from "./ButtonLinks.svelte" import { createEventDispatcher } from "svelte" + import { replaceStateWithQuery } from "$lib/utils" - let searchTerm = "" + export let searchTerm: string | null = "" const dispatch = createEventDispatcher() function onSubmit() { - dispatch("search", { searchTerm }) + if (searchTerm) { + replaceStateWithQuery({ + q: searchTerm, + }) + + dispatch("search", { searchTerm }) + } } diff --git a/src/lib/interfaces.ts b/src/lib/interfaces.ts index a1757d27..018fad02 100644 --- a/src/lib/interfaces.ts +++ b/src/lib/interfaces.ts @@ -37,5 +37,5 @@ export interface FilterOption extends Tag { export type FilterLogic = "and" | "or" export type CustomFilterEvent = { - filter: { filterOptions: FilterOption[]; filterLogic: FilterLogic } + filter: { filterTags: Set; filterLogic: FilterLogic } } diff --git a/src/lib/utils.test.ts b/src/lib/utils.test.ts index f97551a1..f8d00421 100644 --- a/src/lib/utils.test.ts +++ b/src/lib/utils.test.ts @@ -7,8 +7,11 @@ import { removeEmojisFromStr, hasEmoji, sortAlphabeticallyEmojisFirst, + activeTagsSet, + tagQParamSetActive, + tagsForURLParam, } from "./utils" -import type { YoutubeChannel } from "./interfaces" +import type { FilterOption, YoutubeChannel } from "./interfaces" describe("YouTube Utilities", () => { describe("semanticNumber", () => { @@ -159,3 +162,74 @@ describe("Emoji Utilities", () => { }) }) }) + +describe("Tag Utilities", () => { + const filterOptions: FilterOption[] = [ + { + count: 10, + active: false, + name: "name-1", + }, + { + count: 7, + active: false, + name: "name-2", + }, + { + count: 2, + active: true, + name: "name-3", + }, + { + count: 5, + active: false, + name: "👋 name-4", + }, + ] + + describe("activeTagsSet", () => { + it("should return the names of active tags", () => { + const expected = new Set(["name-3"]) + const result = activeTagsSet(filterOptions) + expect(result).toEqual(expected) + }) + }) + + describe("tagQParamSetActive", () => { + it("should turn the filter option that name matches true, others false", () => { + const expected = [ + { + count: 10, + active: false, + name: "name-1", + }, + { + count: 7, + active: true, + name: "name-2", + }, + { + count: 2, + active: false, + name: "name-3", + }, + { + count: 5, + active: false, + name: "👋 name-4", + }, + ] + const result = tagQParamSetActive("name-2", filterOptions) + expect(result).toEqual(expected) + }) + }) + + describe("tagsForURLParam", () => { + it("should return url safe comma separated list", () => { + const expected = "name-3,name-4" + const params = new Set(["name-3", "👋 name-4"]) + const result = tagsForURLParam(params) + expect(result).toEqual(expected) + }) + }) +}) diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 7f3ffbcb..3461d6b7 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,9 +1,10 @@ -import type { YoutubeChannel } from "./interfaces" +import Fuse from "fuse.js" +import type { FilterOption, Resource, YoutubeChannel } from "./interfaces" /** * format subcount for user display - * @param number - * @returns number as string + * @param {number} number + * @returns {string} */ export const semanticNumber = (number: number): string => { // number less than 1000 @@ -24,9 +25,9 @@ export const semanticNumber = (number: number): string => { /** * channels sorted by subcount, climate town always first - * @param a YoutubeChannel - * @param b YoutubeChannel - * @returns sort number + * @param {YoutubeChannel} a + * @param {YoutubeChannel} b + * @returns {number} sort */ export const sortChannelBySubCount = ( a: YoutubeChannel, @@ -47,8 +48,8 @@ export const sortChannelBySubCount = ( /** * Given a channel ID, return the channel data from the array - * @param channelData YoutubeChannel[] - * @param channelId string + * @param {YoutubeChannel[]} channelData + * @param {string} channelId * @returns found channel data */ export const getChannelData = ( @@ -60,9 +61,9 @@ export const getChannelData = ( /** * compare sets and return a new set with any found matches - * @param set1 Set - * @param set2 Set - * @returns new Set() + * @param {Set} set1 + * @param {Set} set2 + * @returns new Set */ export const setIntersection = (set1: Set, set2: Set) => { let intersection = new Set() @@ -74,14 +75,112 @@ export const setIntersection = (set1: Set, set2: Set) => { return intersection } -export const removeEmojisFromStr = (str: string) => { +/** + * get set of active tag names + * @param {FilterOption[]} filterOptions + * @returns {Set} tag names + */ +export const activeTagsSet = (filterOptions: FilterOption[]) => { + const filterTags: Set = new Set( + filterOptions + .filter((option: FilterOption) => option.active === true) + .map((option: FilterOption) => option.name) + ) + return filterTags +} + +/** + * Load tag names from URL into filter object. + * @param {string} querytagNames list of comma separate tag names + * @param {FilterOption[]} filterObject tag options obj array + * @returns {FilterOption[]} updated filterObject with active true on tag name matches + */ +export const tagQParamSetActive = ( + querytagNames: string, + filterObject: FilterOption[] +): FilterOption[] => { + const tagNames = querytagNames.split(",") + + return filterObject.map((option) => { + // Remove emoji from option, and compare to URL (which has no emoji) + option.active = tagNames.includes(removeEmojisFromStr(option.name)) + return option + }) +} + +/** + * update the url params with the record + * and replace the state in history api + * @param {Record | undefined} values + */ +export const replaceStateWithQuery = ( + values: Record | undefined +) => { + const url = new URL(window.location.toString()) + if (values) { + // Clear URL of filter settings + for (let k of url.searchParams.keys()) { + url.searchParams.delete(k) + } + + for (let [k, v] of Object.entries(values)) { + if (!!v) { + url.searchParams.set(k, v) + } + } + } + history.replaceState(history.state, "", url) +} + +/** + * init Fuse with options and run search with provided term + * @param {string} searchTerm + * @param {Resource[]} resourceList + * @returns {Resource[]} filtered resources + */ +export const filterByQuery = ( + searchTerm: string, + resourceList: Resource[] +): Resource[] => { + const options = { + includeScore: true, + threshold: 0.25, + keys: ["description", "title"], + } + + const fuse = new Fuse(resourceList, options) + + const results = fuse.search(searchTerm) + + return results.map((result) => { + return result.item + }) +} + +/** + * remove emojis from given string + * @param {string} str + * @returns {string} + */ +export const removeEmojisFromStr = (str: string): string => { return str.replace(/[\u1000-\uFFFF]+/g, "").trim() } +/** + * check if the string contains emojis + * @param {string} str + * @returns {boolean} + */ export const hasEmoji = (str: string) => { return /[\u1000-\uFFFF]+/g.test(str) } +/** + * sort strings by emoji first then alphabetical + * @param {string} a + * @param {string} b + * @returns + */ export const sortAlphabeticallyEmojisFirst = (a: string, b: string) => { if (hasEmoji(a) && hasEmoji(b)) { const aWithoutEmojis = removeEmojisFromStr(a) @@ -92,3 +191,14 @@ export const sortAlphabeticallyEmojisFirst = (a: string, b: string) => { return a.localeCompare(b) } + +/** + * convert the tag set to a comman separated list, removing emojis + * @param {Set} filterTags + * @returns {string} comma separated tag list + */ +export const tagsForURLParam = (filterTags: Set): string => { + return Array.from(filterTags) + .map((str) => removeEmojisFromStr(str)) + .join(",") +} diff --git a/src/routes/resources/+page.svelte b/src/routes/resources/+page.svelte index 04d082fc..f3874aed 100644 --- a/src/routes/resources/+page.svelte +++ b/src/routes/resources/+page.svelte @@ -2,6 +2,7 @@ import mixpanel from "mixpanel-browser" import Fuse from "fuse.js" + import { page } from "$app/stores" import type { PageData } from "./$types" import { onMount } from "svelte" import { DEFAULT_DISPLAY_LIMIT } from "$lib/constants" @@ -11,7 +12,12 @@ FilterLogic, Resource, } from "$lib/interfaces" - import { setIntersection } from "$lib/utils" + import { + setIntersection, + activeTagsSet, + tagQParamSetActive, + filterByQuery, + } from "$lib/utils" import Search from "$lib/components/Search.svelte" import ListItem from "./ListItem.svelte" import ResourceNav from "$lib/components/ResourceNav.svelte" @@ -19,6 +25,7 @@ import FilterForm from "$lib/components/FilterForm.svelte" export let data: PageData + let searchTerm: string | null let displayedResourceLimit: number = DEFAULT_DISPLAY_LIMIT $: displayedResourceLimit let resources = data.payload.resources @@ -26,6 +33,7 @@ let filterByTags: Resource[] let tagLogicAnd: boolean = true // Whether all the selected tags must match the resource (vs any of the selected tags) // TODO: make this a user preference + let tagLogic: FilterLogic $: tagLogic = tagLogicAnd ? "and" : "or" let tags: Tag[] = data.payload.tags @@ -33,6 +41,7 @@ // Creating form filter options, default view let filterObject: FilterOption[] = [] + $: filterObject for (const tag of tags) { let tagOption: FilterOption = { name: tag.name, @@ -50,41 +59,17 @@ mixpanel.track("Resource Search", { "search term": searchTerm, }) - - const options = { - includeScore: true, - threshold: 0.25, - keys: ["description", "title"], - } - - const fuse = new Fuse(resources, options) - - const results = fuse.search(searchTerm) - - const searchResults = results.map((result) => { - return result.item - }) + const searchResults = filterByQuery(searchTerm, resources) displayedResources = searchResults } const filterResources = ( - event: CustomEvent<{ - filterOptions: FilterOption[] - filterLogic: FilterLogic - }> + event: CustomEvent<{ filterTags: Set; filterLogic: FilterLogic }> ) => { - const { filterOptions, filterLogic } = event.detail - - // Reset displayed resources - displayedResources = [] - - // Tags of interest - let filterTags: Set = new Set( - filterOptions - .filter((option: FilterOption) => option.active === true) - .map((option: FilterOption) => option.name) - ) + const { filterTags, filterLogic } = event.detail + // clear search form when using filters + searchTerm = null // Analytics mixpanel.track("Resource Filter", { @@ -92,10 +77,21 @@ "filter logic": filterLogic, }) + applyTagFilter(filterTags, filterLogic) + } + + const applyTagFilter = ( + filterTags: Set, + filterLogic: FilterLogic + ) => { + // Reset displayed resources + displayedResources = [] + // ! Need to refactor later to make more readable // For intersection, minCommonTags = filterTags.size // For union, minCommonTags = 1 - let minCommonTags = filterLogic === "and" ? filterTags.size : 1 + tagLogicAnd = filterLogic === "and" + let minCommonTags = tagLogicAnd ? filterTags.size : 1 for (let resource of resources) { // Resource tags @@ -118,6 +114,32 @@ const { displayLimit } = event.detail displayedResourceLimit = displayLimit } + + onMount(() => { + const params = Object.fromEntries($page.url.searchParams) + + if (params.q && !params.tags) { + searchTerm = params.q + const searchResults = filterByQuery(searchTerm, resources) + + displayedResources = searchResults + } else { + // clear search form when using filters + searchTerm = null + + if (params.mode) { + tagLogicAnd = params.mode === "and" ? true : false + } + if (params.tags) { + filterObject = tagQParamSetActive(params.tags, filterObject) + } + + applyTagFilter( + activeTagsSet(filterObject), + (params.mode as FilterLogic) ?? "and" + ) + } + })

Resources

@@ -125,10 +147,9 @@

{resources.length} resources and counting!!

- +