Skip to content

Commit

Permalink
Merge pull request #17469 from ElectronicBlueberry/tag-autocomplete-r…
Browse files Browse the repository at this point in the history
…ework

Tag Autocomplete Rework
  • Loading branch information
dannon authored Feb 15, 2024
2 parents c949977 + 1c69980 commit 64a851c
Show file tree
Hide file tree
Showing 8 changed files with 152 additions and 24 deletions.
1 change: 1 addition & 0 deletions client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
"d3v3": "npm:d3@3",
"date-fns": "^2.30.0",
"decode-uri-component": "^0.2.1",
"dexie": "^3.2.5",
"dom-to-image": "^2.6.0",
"dompurify": "^3.0.6",
"dumpmeta-webpack-plugin": "^0.2.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ const emit = defineEmits<{
(e: "input", selected: string[]): void;
/** emitted when a new option is selected, which wasn't part of the options prop */
(e: "addOption", newOption: string): void;
/** emitted when a option is added */
(e: "selected", option: string): void;
}>();
const inputField = ref<HTMLInputElement | null>(null);
Expand Down Expand Up @@ -86,7 +88,9 @@ const filteredOptions = computed(() => {
/** options trimmed to `maxShownOptions` and reordered so the search value appears first */
const trimmedOptions = computed(() => {
const optionsSliced = filteredOptions.value.slice(0, props.maxShownOptions);
const optionsSliced = filteredOptions.value
.slice(0, props.maxShownOptions)
.map((tag) => tag.replace(/^name:/, "#"));
// remove search value to put it in front
const optionsSet = new Set(optionsSliced);
Expand Down Expand Up @@ -154,6 +158,7 @@ function onOptionSelected(option: string) {
set.delete(option);
} else {
set.add(option);
emit("selected", option);
}
emit("input", Array.from(set));
Expand Down
10 changes: 6 additions & 4 deletions client/src/components/TagsMultiselect/StatelessTags.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,12 @@ const mountWithProps = (props) => {
};

jest.mock("@/stores/userTagsStore");
const addLocalTagMock = jest.fn((tag) => tag);
const onNewTagSeenMock = jest.fn((tag) => tag);
useUserTagsStore.mockReturnValue({
userTags: computed(() => autocompleteTags),
addLocalTag: addLocalTagMock,
onNewTagSeen: onNewTagSeenMock,
onTagUsed: jest.fn(),
onMultipleNewTagsSeen: jest.fn(),
});

jest.mock("composables/toast");
Expand Down Expand Up @@ -103,8 +105,8 @@ describe("StatelessTags", () => {
multiselect.find(selectors.options).trigger("click");
await wrapper.vm.$nextTick();

expect(addLocalTagMock.mock.calls.length).toBe(1);
expect(addLocalTagMock.mock.results[0].value).toBe("new_tag");
expect(onNewTagSeenMock.mock.calls.length).toBe(1);
expect(onNewTagSeenMock.mock.results[0].value).toBe("new_tag");
});

it("warns about not allowed tags", async () => {
Expand Down
11 changes: 8 additions & 3 deletions client/src/components/TagsMultiselect/StatelessTags.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { BButton } from "bootstrap-vue";
import { storeToRefs } from "pinia";
import { computed, ref } from "vue";
import { computed, onMounted, ref } from "vue";
import { useToast } from "@/composables/toast";
import { useUid } from "@/composables/utils/uid";
Expand Down Expand Up @@ -37,11 +37,15 @@ const userTagsStore = useUserTagsStore();
const { userTags } = storeToRefs(userTagsStore);
const { warning } = useToast();
onMounted(() => {
userTagsStore.onMultipleNewTagsSeen(props.value);
});
function onAddTag(tag: string) {
const newTag = tag.trim();
if (isValid(newTag)) {
userTagsStore.addLocalTag(newTag);
userTagsStore.onNewTagSeen(newTag);
emit("input", [...props.value, newTag]);
} else {
warning(`"${newTag}" is not a valid tag.`, "Invalid Tag");
Expand Down Expand Up @@ -111,7 +115,8 @@ function onTagClicked(tag: string) {
:placeholder="props.placeholder"
:validator="isValid"
@addOption="onAddTag"
@input="onInput" />
@input="onInput"
@selected="(tag) => userTagsStore.onTagUsed(tag)" />
</div>

<div v-else>
Expand Down
5 changes: 4 additions & 1 deletion client/src/components/Workflow/Editor/Attributes.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,11 @@ const autocompleteTags = ["#named_uer_tag", "abc", "my_tag"];
jest.mock("@/stores/userTagsStore");
useUserTagsStore.mockReturnValue({
userTags: computed(() => autocompleteTags),
addLocalTag: jest.fn(),
onNewTagSeen: jest.fn(),
onTagUsed: jest.fn(),
onMultipleNewTagsSeen: jest.fn(),
});

describe("Attributes", () => {
it("test attributes", async () => {
const localVue = createLocalVue();
Expand Down
129 changes: 114 additions & 15 deletions client/src/stores/userTagsStore.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,126 @@
import { until } from "@vueuse/core";
import Dexie from "dexie";
import { defineStore, storeToRefs } from "pinia";
import { computed, ref } from "vue";
import { computed, ref, watch } from "vue";

import { useHashedUserId } from "@/composables/hashedUserId";
import { assertDefined, ensureDefined } from "@/utils/assertions";

import { useUserStore } from "./userStore";

export const useUserTagsStore = defineStore("userTagsStore", () => {
const localTags = ref<string[]>([]);
interface StoredTag {
id?: number;
tag: string;
userHash: string;
lastUsed: number;
}

class UserTagStoreDatabase extends Dexie {
tags!: Dexie.Table<StoredTag, number>;

constructor() {
super("userTagStoreDatabase");
this.version(1).stores({ tags: "++id, userHash, lastUsed" });
}
}

const maxDbEntriesPerUser = 10000;

function normalizeTag(tag: string) {
return tag.replace(/^#/, "name:");
}

export const useUserTagsStore = defineStore("userTagsStore", () => {
const { currentUser } = storeToRefs(useUserStore());
const { hashedUserId } = useHashedUserId(currentUser);

const db = new UserTagStoreDatabase();
const tags = ref<StoredTag[]>([]);
const dbLoaded = ref(false);

watch(
() => hashedUserId.value,
async (userHash) => {
if (userHash) {
tags.value = await db.tags.where("userHash").equals(userHash).sortBy("lastUsed");

console.log(tags.value);

if (tags.value.length > maxDbEntriesPerUser) {
await removeOldestEntries(tags.value.length - maxDbEntriesPerUser);
}

dbLoaded.value = true;
}
},
{ immediate: true }
);

/** removes the x oldest tags from the database */
async function removeOldestEntries(count: number) {
const oldestTags = tags.value.splice(0, count);
await db.tags.bulkDelete(oldestTags.map((o) => o.id!));
}

/** tags as string array */
const userTags = computed(() => {
let tags: string[];
if (currentUser.value && !currentUser.value.isAnonymous) {
tags = [...(currentUser.value.tags_used ?? []), ...localTags.value];
} else {
tags = localTags.value;
}
const tagSet = new Set(tags);
return Array.from(tagSet).map((tag) => tag.replace(/^name:/, "#"));
return tags.value.map((o) => o.tag).reverse();
});

const addLocalTag = (tag: string) => {
localTags.value.push(tag);
};
async function onNewTagSeen(tag: string) {
await until(dbLoaded).toBe(true);

assertDefined(hashedUserId.value);
tag = normalizeTag(tag);

const tagSet = new Set(userTags.value);

if (!tagSet.has(tag)) {
const tagObject: StoredTag = {
tag,
userHash: hashedUserId.value,
lastUsed: Date.now(),
};

tags.value.push(tagObject);
await db.tags.add(tagObject);
}
}

async function onMultipleNewTagsSeen(newTags: string[]) {
await until(dbLoaded).toBe(true);

const userHash = ensureDefined(hashedUserId.value);
const tagSet = new Set(userTags.value);

// only the ones that really are new
const filteredNewTags = newTags.map(normalizeTag).filter((tag) => !tagSet.has(tag));

if (filteredNewTags.length > 0) {
const now = Date.now();
const newTagObjects: StoredTag[] = filteredNewTags.map((tag) => ({
tag,
userHash,
lastUsed: now,
}));

tags.value = tags.value.concat(newTagObjects);
await db.tags.bulkAdd(newTagObjects);
}
}

async function onTagUsed(tag: string) {
await until(dbLoaded).toBe(true);
tag = normalizeTag(tag);

const storedTag = tags.value.find((o) => o.tag === tag);
const id = storedTag?.id;

if (id !== undefined) {
// put instead of update, because `removeOldestEntries` may have deleted this tag on init
await db.tags.put({ ...storedTag, lastUsed: Date.now() } as StoredTag, id);
}
}

return { localTags, userTags, addLocalTag };
return { userTags, onNewTagSeen, onTagUsed, onMultipleNewTagsSeen };
});
1 change: 1 addition & 0 deletions client/tests/jest/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ module.exports = {
"<rootDir>/node_modules/rxjs/dist/esm/internal/scheduler/AsyncScheduler.js",
"^@/(.*)$": "<rootDir>/src/$1",
"^@tests/(.*)$": "<rootDir>/tests/$1",
dexie: "<rootDir>/node_modules/dexie/dist/dexie.js",
},
modulePathIgnorePatterns: ["<rootDir>/src/.*/__mocks__"],
rootDir: path.join(__dirname, "../../"),
Expand Down
12 changes: 12 additions & 0 deletions client/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -5054,6 +5054,13 @@ detect-node@^2.0.4:
resolved "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz"
integrity sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==

dexie@^3.2.5:
version "3.2.5"
resolved "https://registry.yarnpkg.com/dexie/-/dexie-3.2.5.tgz#f68e34b98e0e5e3412cf86b540a2cc6cbf9b0266"
integrity sha512-MA7vYQvXxWN2+G50D0GLS4FqdYUyRYQsN0FikZIVebOmRoNCSCL9+eUbIF80dqrfns3kmY+83+hE2GN9CnAGyA==
dependencies:
karma-safari-launcher "^1.0.0"

diff-sequences@^29.6.3:
version "29.6.3"
resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-29.6.3.tgz#4deaf894d11407c51efc8418012f9e70b84ea921"
Expand Down Expand Up @@ -7938,6 +7945,11 @@ just-debounce@^1.0.0:
resolved "https://registry.npmjs.org/just-debounce/-/just-debounce-1.1.0.tgz"
integrity sha512-qpcRocdkUmf+UTNBYx5w6dexX5J31AKK1OmPwH630a83DdVVUIngk55RSAiIGpQyoH0dlr872VHfPjnQnK1qDQ==

karma-safari-launcher@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/karma-safari-launcher/-/karma-safari-launcher-1.0.0.tgz#96982a2cc47d066aae71c553babb28319115a2ce"
integrity sha512-qmypLWd6F2qrDJfAETvXDfxHvKDk+nyIjpH9xIeI3/hENr0U3nuqkxaftq73PfXZ4aOuOChA6SnLW4m4AxfRjQ==

kind-of@^3.0.2, kind-of@^3.0.3, kind-of@^3.2.0:
version "3.2.2"
resolved "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz"
Expand Down

0 comments on commit 64a851c

Please sign in to comment.