Skip to content

Commit

Permalink
Merge pull request #361 from dmlb/query-params
Browse files Browse the repository at this point in the history
Resource Filter Settings in URL
  • Loading branch information
VeckoTheGecko authored Dec 28, 2023
2 parents 9f3f38a + c28697c commit d29f5ac
Show file tree
Hide file tree
Showing 6 changed files with 301 additions and 58 deletions.
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

0 comments on commit d29f5ac

Please sign in to comment.