Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Resource Filter Settings in URL #361

Merged
merged 13 commits into from
Dec 28, 2023
47 changes: 39 additions & 8 deletions src/lib/components/FilterForm.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -11,33 +11,64 @@
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"
import ButtonLinks from "$lib/components/ButtonLinks.svelte"
const dispatch = createEventDispatcher<CustomFilterEvent>()
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(
(option: FilterOption) => option.active === true
)

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 })
}
</script>

Expand Down Expand Up @@ -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"
>
<input
bind:checked={filterLogicAnd}
aria-checked={filterLogicAnd}
bind:checked={filterLogicAndCtrl}
aria-checked={filterLogicAndCtrl}
id="switch"
role="switch"
type="checkbox"
Expand Down
11 changes: 9 additions & 2 deletions src/lib/components/Search.svelte
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
<script lang="ts">
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 })
}
}
</script>

Expand Down
2 changes: 1 addition & 1 deletion src/lib/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>; filterLogic: FilterLogic }
}
76 changes: 75 additions & 1 deletion src/lib/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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<string>(["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<string>(["name-3", "👋 name-4"])
const result = tagsForURLParam(params)
expect(result).toEqual(expected)
})
})
})
134 changes: 122 additions & 12 deletions src/lib/utils.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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,
Expand All @@ -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 = (
Expand All @@ -60,9 +61,9 @@ export const getChannelData = (

/**
* compare sets and return a new set with any found matches
* @param set1 Set<any>
* @param set2 Set<any>
* @returns new Set()
* @param {Set<any>} set1
* @param {Set<any>} set2
* @returns new Set
*/
export const setIntersection = (set1: Set<any>, set2: Set<any>) => {
let intersection = new Set()
Expand All @@ -74,14 +75,112 @@ export const setIntersection = (set1: Set<any>, set2: Set<any>) => {
return intersection
}

export const removeEmojisFromStr = (str: string) => {
/**
* get set of active tag names
* @param {FilterOption[]} filterOptions
* @returns {Set<string>} tag names
*/
export const activeTagsSet = (filterOptions: FilterOption[]) => {
const filterTags: Set<string> = 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<string, string> | undefined} values
*/
export const replaceStateWithQuery = (
values: Record<string, string> | 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)
Expand All @@ -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<string>} filterTags
* @returns {string} comma separated tag list
*/
export const tagsForURLParam = (filterTags: Set<string>): string => {
return Array.from(filterTags)
.map((str) => removeEmojisFromStr(str))
.join(",")
}
Loading