Skip to content

Commit

Permalink
Sync url (#621)
Browse files Browse the repository at this point in the history
Closes #575 

Still needs more testing before ready. Will _not_ hook up the node
search using this because that is more complicated and manual.

Can add the LZW text-annotator sync to this PR too if you want.

- make "replace" param of link component manual
- split composables into their own files and folder
- add generic use-param composable that creates a two-way synced ref
with a url param
- hook up use-param to phenotype explorer

---------

Co-authored-by: glass-ships <[email protected]>
  • Loading branch information
vincerubinetti and glass-ships authored Mar 12, 2024
1 parent eb35fd0 commit 25f4d33
Show file tree
Hide file tree
Showing 29 changed files with 373 additions and 206 deletions.
1 change: 1 addition & 0 deletions frontend/src/components/AppHeading.vue
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
<AppLink
v-if="link"
:to="'#' + link"
:replace="true"
class="anchor"
:aria-label="'Link to this section'"
>
Expand Down
7 changes: 5 additions & 2 deletions frontend/src/components/AppLink.vue
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
...routeTo,
state: mapValues(state, (value) => stringify(value)),
}"
:replace="!!routeTo.hash && !routeTo.path"
:replace="replace"
>
<!-- use vue router component for relative urls -->
<slot />
Expand All @@ -46,6 +46,8 @@ const router = useRouter();
type Props = {
/** location to link to */
to: string | RouteLocationRaw;
/** whether to replace url instead of push */
replace?: boolean;
/**
* state data to attach on navigation. object/array values get stringified.
* https://developer.mozilla.org/en-US/docs/Web/API/History/pushState
Expand All @@ -56,8 +58,9 @@ type Props = {
};

const props = withDefaults(defineProps<Props>(), {
noIcon: false,
replace: false,
state: undefined,
noIcon: false,
});

const slots = useSlots();
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/components/AppSelectAutocomplete.vue
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,8 @@ export type Option = {
<script setup lang="ts">
import { computed, nextTick, ref, watch } from "vue";
import { uniqueId } from "lodash";
import { useFloating, useQuery } from "@/util/composables";
import { useFloating } from "@/composables/use-floating";
import { useQuery } from "@/composables/use-query";
import { wrap } from "@/util/math";
import AppTextbox from "./AppTextbox.vue";

Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/AppSelectMulti.vue
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ export type Options = Option[];
<script setup lang="ts">
import { computed, nextTick, ref, watch } from "vue";
import { isEqual, uniqueId } from "lodash";
import { useFloating } from "@/util/composables";
import { useFloating } from "@/composables/use-floating";
import { wrap } from "@/util/math";
import type AppButton from "./AppButton.vue";

Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/AppSelectSingle.vue
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ export type Options = Option[];
<script setup lang="ts">
import { nextTick, ref, watch } from "vue";
import { uniqueId } from "lodash";
import { useFloating } from "@/util/composables";
import { useFloating } from "@/composables/use-floating";
import { wrap } from "@/util/math";

type Props = {
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/components/AppSelectTags.vue
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,8 @@ export type Option = {
<script setup lang="ts">
import { computed, nextTick, ref, watch } from "vue";
import { isEqual, uniqBy, uniqueId } from "lodash";
import { useFloating, useQuery } from "@/util/composables";
import { useFloating } from "@/composables/use-floating";
import { useQuery } from "@/composables/use-query";
import { wrap } from "@/util/math";
import { copyToClipboard } from "@/util/string";
import AppInput from "./AppInput.vue";
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/AppTable.vue
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@ export type Sort<Key extends string = string> = {
<script setup lang="ts" generic="Datum extends object">
import { computed, watch, type VNode } from "vue";
import { useLocalStorage } from "@vueuse/core";
import { useScrollable } from "@/util/composables";
import { useScrollable } from "@/composables/use-scrollable";
import type { Options } from "./AppSelectMulti.vue";
import AppSelectMulti from "./AppSelectMulti.vue";
import AppSelectSingle from "./AppSelectSingle.vue";
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/TheFeedbackForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ import parser from "ua-parser-js";
import { useLocalStorage } from "@vueuse/core";
import { postFeedback } from "@/api/feedback";
import AppTextbox from "@/components/AppTextbox.vue";
import { useQuery } from "@/util/composables";
import { useQuery } from "@/composables/use-query";
import { collapse } from "@/util/string";

/** route info */
Expand Down
1 change: 1 addition & 0 deletions frontend/src/components/TheTableOfContents.vue
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
v-for="(entry, index) in entries"
:key="index"
:to="'#' + entry.id"
:replace="true"
:class="['entry', { active: active === index }]"
:aria-current="active === index"
@click="active = index"
Expand Down
60 changes: 60 additions & 0 deletions frontend/src/composables/use-floating.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import type { CSSProperties, Ref } from "vue";
import { ref } from "vue";
import { debounce } from "lodash";
import { computePosition, flip, shift, size } from "@floating-ui/dom";
import { useEventListener } from "@vueuse/core";

/** use floating-ui to position dropdown */

export const useFloating = (
anchor: Ref<HTMLElement | undefined>,
dropdown: Ref<HTMLElement | undefined>,
fit = false,
): { calculate: () => Promise<void>; style: Ref<CSSProperties> } => {
/** style of dropdown */
const style = ref<CSSProperties>({
position: "absolute",
left: "0px",
top: "0px",
minWidth: "0px",
});

/** floating-ui options */
const options = {
middleware: [
flip(),
shift({ padding: 5 }),
size({
/** update min width based on target width */
apply: ({ rects }) => {
if (fit) style.value.width = rects.reference.width + "px";
else style.value.minWidth = rects.reference.width + "px";
},
}),
],
};

/** func to recompute position on command */
async function calculate() {
/** make sure we have needed element references */
if (!anchor.value || !dropdown.value) return;

/** use floating-ui to compute position of dropdown */
const { x, y } = await computePosition(
anchor.value,
dropdown.value,
options,
);

/** set style from position */
style.value.left = x + "px";
style.value.top = y + "px";
}

/** automatically run calculate on reflow events */
const debounced = debounce(calculate, 100);
useEventListener(window, "scroll", debounced);
useEventListener(window, "resize", debounced);

return { calculate, style };
};
141 changes: 141 additions & 0 deletions frontend/src/composables/use-param.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { ref, shallowRef, watch } from "vue";
import { type Router } from "vue-router";
import { isEqual, round } from "lodash";

/**
* single "reactive" object url to sync with "raw" url. needed so we can batch
* together multiple synchronous param/hash sets into a single push/replace
* history entry. vue-router does batch together multiple synchronous
* pushes/replaces, but does not accommodate keeping/merging params, because
* currentRoute object only updates at end of batch.
*/
export const url = ref<{ [key: string]: string }>({});

/** connect to router */
export function initRouter(router: Router) {
/** flag to check if router.push/replace just occurred */
let justPushed = false;

/** when reactive url changes */
watch(
url,
() => {
const from = router.currentRoute.value;
/** update url to include params. preserve hash. */
const to = { query: url.value, hash: from.hash };

if (!isEqual(url.value, router.currentRoute.value.query)) {
/** push/replace based on mode */
router[mode === "replace" ? "replace" : "push"](to);
justPushed = true;
}
},
{ deep: true, immediate: true },
);

/** after raw url changes */
router.afterEach((to) => {
/**
* if raw url just changed due to url object change, don't change url object
* again
*/
if (justPushed) {
justPushed = false;
return;
}

/** delete params in url object */
for (const key of Object.keys(url.value))
if (!to.query[key]) delete url.value[key];

/** add/change params in url object */
for (const [key, value] of Object.entries(to.query))
if (typeof value === "string") url.value[key] = value;
});
}

/** how to update tab history */
const mode: "push" | "replace" = "push";

/** reactive variable 2-way-synced with param in url */
export function useParam<T>(
key: string,
{ parse, stringify }: Param<T>,
initialValue: T,
) {
/** https://github.com/vuejs/composition-api/issues/483 */
/** reactive "local" var that should act like slice of reactive "full" url */
const variable = shallowRef(initialValue);

watch(
/** when param in full url object changes */
() => url.value[key],
() => {
/**
* get updated value from url object. if key no longer there (e.g. user
* went back to url without it), reset to initial value.
*/
const value = url.value[key] || stringify(initialValue);
/**
* stringify process is sometimes "lossy" (e.g. rounding decimal places),
* so compare values after that process
*/
if (value === stringify(variable.value)) return;
/** convert raw url string value to actual var value */
variable.value = parse(value);
},
{ immediate: true },
);

watch(
/** when local var changes */
variable,
() => {
/** convert actual var value to string for url */
const value = stringify(variable.value);
/** set var value */
if (value) url.value[key] = value;
/** if "empty", such as empty string or array, delete param from url */ else
delete url.value[key];
},
/**
* no "immediate", so url doesn't update until var changes from initial
* value (presumably from user interaction)
*/
);

return variable;
}

/** generic parameter type, with methods to encode/decode to/from string (url) */
export type Param<T> = {
parse: (value: string) => T;
stringify: (value: T) => string;
};

/** treat param as string */
export const stringParam = (): Param<string> => ({
parse: (value) => value,
stringify: (value) => value,
});

/** treat param as number */
export const numberParam = (precision?: number): Param<number> => ({
parse: (value) => Number(value) || 0,
stringify: (value) => String(round(value || 0, precision)),
});

/** treat param as boolean */
export const booleanParam = (
type: "number" | "string" = "number",
): Param<boolean> => ({
parse: (value) =>
value.toLowerCase() === (type === "string" ? "true" : "1") ? true : false,
stringify: (value) => (type === "string" ? String(value) : value ? "1" : "0"),
});

/** higher-order array param of other params */
export const arrayParam = <T>(param: Param<T>): Param<T[]> => ({
parse: (value) => value.split(",").map(param.parse),
stringify: (value) => value.map(param.stringify).join(","),
});
82 changes: 82 additions & 0 deletions frontend/src/composables/use-query.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import type { Ref } from "vue";
import { ref } from "vue";

/**
* inspired by tanstack-query. simple query manager/wrapper for making queries
* in components. reduces repetitive boilerplate code for loading/error states,
* try/catch blocks, de-duplicating requests, etc.
*/
export const useQuery = <Data, Args extends unknown[]>(
/**
* main async func that returns data. should be side-effect free to avoid race
* conditions, because multiple can be running at same time.
*/
func: (...args: Args) => Promise<Data>,
/** default value used for data before done loading and on error. */
defaultValue: Data,
/**
* func to run on success. use for side effects. only gets called on latest of
* concurrent runs.
*/
onSuccess?: (
/** response data */
response: Data,
/** props passed to main func */
props: Args,
) => void,
) => {
/** query state/status */
const isLoading = ref(false);
const isError = ref(false);
const isSuccess = ref(false);

/** query results */
const data = ref<Data>(defaultValue) as Ref<Data>;
/** https://github.com/vuejs/composition-api/issues/483 */

/** latest query id, unique to this useQuery instance */
let latest;

/** wrapped query function */
async function query(...args: Args): Promise<void> {
try {
/** unique id for current run */
const current = Symbol();
latest = current;

/** reset state */
isLoading.value = true;
isError.value = false;
isSuccess.value = false;
data.value = defaultValue;

/** run provided function */
const result = await func(...args);

/** if this run still the latest */
if (current === latest) {
/** assign results to data */
data.value = result;

/** update state */
isLoading.value = false;
isSuccess.value = true;

/** on success callback */
if (onSuccess) onSuccess(result, args);
} else {
/** otherwise, log special "stale" error */
console.error("Stale query");
}
} catch (error) {
/** log error */
console.error(error);

/** update state */
isError.value = true;
isLoading.value = false;
}
}

return { query, data, isLoading, isError, isSuccess };
};
Loading

0 comments on commit 25f4d33

Please sign in to comment.