From 3240ba1bcef8202aa693cb745d16f9ce89d3f366 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jana=20Vlachov=C3=A1?= <65499282+janavlachova@users.noreply.github.com> Date: Sat, 11 Jan 2025 16:08:58 +0100 Subject: [PATCH] [studio] display db users in expanded row #1460 (#1479) * studio - db details in db table * studio - db users * studio - db users * studio - unit test * studio - unit tests * studio - remove comments * studio - dbdetails stub in unit test * studio - type fixed --- agdb_studio/src/assets/base.css | 5 + agdb_studio/src/assets/button.less | 2 +- .../base/content/AgdbContent.spec.ts | 30 +++- .../components/base/content/AgdbContent.vue | 34 ++++- .../components/base/modal/AgdbModal.spec.ts | 16 +- .../src/components/base/modal/AgdbModal.vue | 6 +- .../base/table/AgdbCellMenu.spec.ts | 4 +- .../components/base/table/AgdbCellMenu.vue | 4 +- .../base/table/AgdbTableRow.spec.ts | 3 +- .../components/base/table/AgdbTableRow.vue | 12 +- .../src/components/db/DbDetails.spec.ts | 143 ++++++++++++++++++ agdb_studio/src/components/db/DbDetails.vue | 112 ++++++++++++++ agdb_studio/src/components/db/DbTable.vue | 2 +- .../src/components/layouts/MainLayout.vue | 2 +- .../{FadeTrasition.vue => FadeTransition.vue} | 0 agdb_studio/src/composables/content/utils.ts | 6 +- agdb_studio/src/composables/db/dbConfig.ts | 4 +- .../src/composables/db/dbDetails.spec.ts | 87 +++++++++++ agdb_studio/src/composables/db/dbDetails.ts | 122 +++++++++++++++ agdb_studio/src/composables/db/dbStore.ts | 7 + .../src/composables/db/dbUsersStore.spec.ts | 69 +++++++++ .../src/composables/db/dbUsersStore.ts | 57 +++++++ .../src/composables/modal/modal.spec.ts | 18 +-- agdb_studio/src/composables/modal/modal.ts | 14 +- .../src/composables/table/tableConfig.ts | 2 +- agdb_studio/src/composables/table/types.ts | 4 +- .../src/composables/table/utils.spec.ts | 1 + agdb_studio/src/tests/apiMock.ts | 6 + agdb_studio/src/types/asyncComponents.d.ts | 1 + agdb_studio/src/types/base.d.ts | 17 ++- agdb_studio/src/utils/asyncComponents.spec.ts | 9 ++ agdb_studio/src/utils/asyncComponents.ts | 13 ++ 32 files changed, 760 insertions(+), 52 deletions(-) create mode 100644 agdb_studio/src/components/db/DbDetails.spec.ts create mode 100644 agdb_studio/src/components/db/DbDetails.vue rename agdb_studio/src/components/transitions/{FadeTrasition.vue => FadeTransition.vue} (100%) create mode 100644 agdb_studio/src/composables/db/dbDetails.spec.ts create mode 100644 agdb_studio/src/composables/db/dbDetails.ts create mode 100644 agdb_studio/src/composables/db/dbUsersStore.spec.ts create mode 100644 agdb_studio/src/composables/db/dbUsersStore.ts create mode 100644 agdb_studio/src/types/asyncComponents.d.ts create mode 100644 agdb_studio/src/utils/asyncComponents.spec.ts create mode 100644 agdb_studio/src/utils/asyncComponents.ts diff --git a/agdb_studio/src/assets/base.css b/agdb_studio/src/assets/base.css index 8e480440..eece8461 100644 --- a/agdb_studio/src/assets/base.css +++ b/agdb_studio/src/assets/base.css @@ -20,10 +20,13 @@ --text-light-2: rgba(60, 60, 60, 0.66); --text-dark-1: var(--white); --text-dark-2: rgba(235, 235, 235, 0.64); + --text-dark-3: rgba(235, 235, 235, 0.4); --base-font: "Red Hat Display", sans-serif; --orange: #ffa02c; + --red: #af2836; + --green: #1e7732; } :root { @@ -37,6 +40,7 @@ --color-heading: var(--text-light-1); --color-text: var(--text-light-1); + --color-text-muted: var(--text-light-2); --section-gap: 160px; } @@ -52,6 +56,7 @@ --color-heading: var(--text-dark-1); --color-text: var(--text-dark-2); + --color-text-muted: var(--text-dark-3); } } diff --git a/agdb_studio/src/assets/button.less b/agdb_studio/src/assets/button.less index a8cf61a8..c8d64492 100644 --- a/agdb_studio/src/assets/button.less +++ b/agdb_studio/src/assets/button.less @@ -40,7 +40,7 @@ .button-danger { --backgroundColor: #dc3545; --color: var(--white); - --borderColor: #af2836; + --borderColor: var(--red); } .button-transparent { diff --git a/agdb_studio/src/components/base/content/AgdbContent.spec.ts b/agdb_studio/src/components/base/content/AgdbContent.spec.ts index 0e9a08c9..d8e28e28 100644 --- a/agdb_studio/src/components/base/content/AgdbContent.spec.ts +++ b/agdb_studio/src/components/base/content/AgdbContent.spec.ts @@ -25,7 +25,7 @@ describe("AgdbContent", () => { ], }, { - component: "my-component", + component: "my-component" as unknown as AsyncComponent, }, { input: { @@ -89,4 +89,32 @@ describe("AgdbContent", () => { const input = wrapper.find("input"); expect(input.element.matches(":focus")).toBe(true); }); + it("should render select input and change value", async () => { + const inputValue = ref("test"); + addInput(testKey, "test", inputValue); + const wrapper = mount(AgdbContent, { + props: { + content: [ + { + input: { + key: "test", + type: "select", + label: "Test input", + options: [ + { value: "test", label: "Test" }, + { value: "test2", label: "Test2" }, + ], + }, + }, + ], + contentKey: testKey, + }, + }); + const select = wrapper.find("select"); + expect(select.element.value).toBe("test"); + expect(getInputValue(testKey, "test")).toBe("test"); + select.element.value = "test2"; + await select.trigger("change"); + expect(getInputValue(testKey, "test")).toBe("test2"); + }); }); diff --git a/agdb_studio/src/components/base/content/AgdbContent.vue b/agdb_studio/src/components/base/content/AgdbContent.vue index d21c5f72..7f6c500b 100644 --- a/agdb_studio/src/components/base/content/AgdbContent.vue +++ b/agdb_studio/src/components/base/content/AgdbContent.vue @@ -7,7 +7,7 @@ const props = defineProps({ contentKey: { type: Symbol, required: true }, }); -const { getContentInputs, setInputValue } = useContentInputs(); +const { getContentInputs, setInputValue, getInputValue } = useContentInputs(); const inputs = getContentInputs(props.contentKey) ?? new Map(); const autofocusElement = ref(); @@ -35,8 +35,32 @@ onMounted(() => {
+ { - const { showModal, hideModal } = useModal(); + const { openModal, closeModal } = useModal(); beforeEach(() => { - hideModal(); + closeModal(); }); it("shows a modal when called", async () => { @@ -15,7 +15,7 @@ describe("AgdbModal", () => { attachTo: document.body, }); expect(wrapper.isVisible()).toBe(false); - showModal({ + openModal({ header: "Test Header", content: convertArrayOfStringsToContent(["Test Body"]), }); @@ -26,7 +26,7 @@ describe("AgdbModal", () => { const wrapper = mount(AgdbModal, { attachTo: document.body, }); - showModal({ + openModal({ header: "Test Header", content: convertArrayOfStringsToContent(["Test Body"]), }); @@ -39,7 +39,7 @@ describe("AgdbModal", () => { const wrapper = mount(AgdbModal, { attachTo: document.body, }); - showModal({ + openModal({ header: "Test Header", content: convertArrayOfStringsToContent(["Test Body"]), }); @@ -54,7 +54,7 @@ describe("AgdbModal", () => { const wrapper = mount(AgdbModal, { attachTo: document.body, }); - showModal({ + openModal({ header: "Test Header", content: convertArrayOfStringsToContent(["Test Body"]), buttons: [ @@ -75,7 +75,7 @@ describe("AgdbModal", () => { const wrapper = mount(AgdbModal, { attachTo: document.body, }); - showModal({ + openModal({ header: "Test Header", content: convertArrayOfStringsToContent(["Test Body"]), onConfirm: () => {}, @@ -90,7 +90,7 @@ describe("AgdbModal", () => { const wrapper = mount(AgdbModal, { attachTo: document.body, }); - showModal({ + openModal({ header: "Test Header", content: [ { diff --git a/agdb_studio/src/components/base/modal/AgdbModal.vue b/agdb_studio/src/components/base/modal/AgdbModal.vue index 1463b556..79cb0bb3 100644 --- a/agdb_studio/src/components/base/modal/AgdbModal.vue +++ b/agdb_studio/src/components/base/modal/AgdbModal.vue @@ -5,7 +5,7 @@ import AgdbContent from "../content/AgdbContent.vue"; import { KEY_MODAL } from "@/composables/modal/constants"; import { nextTick, ref, watch } from "vue"; -const { modal, buttons, hideModal, modalIsVisible } = useModal(); +const { modal, buttons, closeModal, modalIsVisible } = useModal(); const autofocusElement = ref(); @@ -27,7 +27,7 @@ watch(modalIsVisible, async () => {
- details - +
diff --git a/agdb_studio/src/components/db/DbDetails.spec.ts b/agdb_studio/src/components/db/DbDetails.spec.ts new file mode 100644 index 00000000..ed1ce559 --- /dev/null +++ b/agdb_studio/src/components/db/DbDetails.spec.ts @@ -0,0 +1,143 @@ +import { mount } from "@vue/test-utils"; +import { describe, beforeEach, vi, it, expect } from "vitest"; +import DbDetails from "./DbDetails.vue"; +import { ref } from "vue"; + +const { fetchDbUsers, isDbRoleType, handleRemoveUser, handleAddUser } = + vi.hoisted(() => { + return { + fetchDbUsers: vi.fn(), + isDbRoleType: vi.fn().mockReturnValue(true), + handleRemoveUser: vi.fn(), + handleAddUser: vi.fn(), + }; + }); + +vi.mock("@/composables/db/dbUsersStore", () => { + return { + useDbUsersStore: () => { + return { + fetchDbUsers, + isDbRoleType, + }; + }, + }; +}); + +const canEditUsers = ref(true); + +vi.mock("@/composables/db/dbDetails", () => { + return { + useDbDetails: () => { + return { + users: ref([ + { + username: "testUser", + role: "read", + }, + { + username: "testUser2", + role: "write", + }, + { + username: "testUser3", + role: "admin", + }, + ]), + dbName: ref("testOwner/testDb"), + canEditUsers: canEditUsers, + handleRemoveUser, + handleAddUser, + }; + }, + }; +}); + +describe("DbDetails", () => { + beforeEach(() => { + vi.clearAllMocks(); + canEditUsers.value = true; + }); + it("should render users", async () => { + const wrapper = mount(DbDetails); + await wrapper.vm.$nextTick(); + expect(wrapper.find("header").text()).toContain("testOwner/testDb"); + const usernames = wrapper.findAll(".username"); + expect(usernames.length).toBe(3); + expect(usernames[0].text()).toContain("testUser"); + expect(usernames[1].text()).toContain("testUser2"); + expect(usernames[2].text()).toContain("testUser3"); + + const roles = wrapper.findAll(".role"); + expect(roles.length).toBe(3); + expect(roles[0].text()).toContain("(R)"); + expect(roles[1].text()).toContain("(W)"); + expect(roles[2].text()).toContain("(A)"); + }); + + it("should add a user", async () => { + const wrapper = mount(DbDetails); + await wrapper.vm.$nextTick(); + await wrapper.find(".add-button").trigger("click"); + await wrapper.vm.$nextTick(); + + expect(handleAddUser).toHaveBeenCalled(); + }); + + it("should remove a user", async () => { + const wrapper = mount(DbDetails); + await wrapper.vm.$nextTick(); + await wrapper.find(".remove-button").trigger("click"); + await wrapper.vm.$nextTick(); + + expect(handleRemoveUser).toHaveBeenCalled(); + }); + + it("should not render add button if not admin", async () => { + canEditUsers.value = false; + const wrapper = mount(DbDetails, { + props: { + row: { + role: "read", + }, + }, + }); + + await wrapper.vm.$nextTick(); + const addButton = wrapper.find(".add-button"); + expect(addButton.exists()).toBe(false); + }); + + it("should not render remove button if not admin", async () => { + canEditUsers.value = false; + const wrapper = mount(DbDetails, { + props: { + row: { + role: "read", + }, + }, + }); + + await wrapper.vm.$nextTick(); + const removeButton = wrapper.find(".remove-button"); + expect(removeButton.exists()).toBe(false); + }); + + it("should not render remove button if user is owner", async () => { + const wrapper = mount(DbDetails, { + props: { + row: { + owner: "testUser3", + role: "admin", + db: "testDb", + }, + }, + }); + + await wrapper.vm.$nextTick(); + const items = wrapper.findAll(".user-item"); + expect(items.length).toBe(3); + expect(items[0].find(".remove-button").exists()).toBe(true); + expect(items[2].find(".remove-button").exists()).toBe(false); + }); +}); diff --git a/agdb_studio/src/components/db/DbDetails.vue b/agdb_studio/src/components/db/DbDetails.vue new file mode 100644 index 00000000..cd1acdc8 --- /dev/null +++ b/agdb_studio/src/components/db/DbDetails.vue @@ -0,0 +1,112 @@ + + + + + diff --git a/agdb_studio/src/components/db/DbTable.vue b/agdb_studio/src/components/db/DbTable.vue index a15ffc9d..56aeed17 100644 --- a/agdb_studio/src/components/db/DbTable.vue +++ b/agdb_studio/src/components/db/DbTable.vue @@ -13,7 +13,7 @@ const TABLE_KEY = Symbol("databases"); addTable({ name: TABLE_KEY, columns: dbColumns, - rowDetailsComponent: "DbTableRowDetails", + rowDetailsComponent: "DbDetails", }); watchEffect(() => { diff --git a/agdb_studio/src/components/layouts/MainLayout.vue b/agdb_studio/src/components/layouts/MainLayout.vue index 1f79b20e..ed1e4927 100644 --- a/agdb_studio/src/components/layouts/MainLayout.vue +++ b/agdb_studio/src/components/layouts/MainLayout.vue @@ -4,7 +4,7 @@ import { useAuth } from "@/composables/user/auth"; import LogoIcon from "@/components/base/icons/LogoIcon.vue"; import { useAccount } from "@/composables/user/account"; import AgdbModal from "@/components/base/modal/AgdbModal.vue"; -import FadeTrasition from "@/components/transitions/FadeTrasition.vue"; +import FadeTrasition from "@/components/transitions/FadeTransition.vue"; const { logout } = useAuth(); const { username } = useAccount(); diff --git a/agdb_studio/src/components/transitions/FadeTrasition.vue b/agdb_studio/src/components/transitions/FadeTransition.vue similarity index 100% rename from agdb_studio/src/components/transitions/FadeTrasition.vue rename to agdb_studio/src/components/transitions/FadeTransition.vue diff --git a/agdb_studio/src/composables/content/utils.ts b/agdb_studio/src/composables/content/utils.ts index b10a1a18..496c3585 100644 --- a/agdb_studio/src/composables/content/utils.ts +++ b/agdb_studio/src/composables/content/utils.ts @@ -1,13 +1,15 @@ -type ConvertParams = { +export type ConvertParams = { emphesizedWords?: string[]; }; +export const EMPHESIZED_CLASSNAME = "emphesized"; + const emphesizeWords = (text: string, words: string[]): Paragraph[] => { const parts = text.split(new RegExp(`(${words.join("|")})`, "g")); return parts.map((part) => { if (words.includes(part)) { - return { text: part, className: "emphesized" }; + return { text: part, className: EMPHESIZED_CLASSNAME }; } return { text: part }; }); diff --git a/agdb_studio/src/composables/db/dbConfig.ts b/agdb_studio/src/composables/db/dbConfig.ts index 56c862f7..f0a51705 100644 --- a/agdb_studio/src/composables/db/dbConfig.ts +++ b/agdb_studio/src/composables/db/dbConfig.ts @@ -7,7 +7,7 @@ import { KEY_MODAL } from "../modal/constants"; import useModal from "../modal/modal"; const { getInputValue } = useContentInputs(); -const { showModal } = useModal(); +const { openModal } = useModal(); export type DbActionProps = ActionProps; @@ -29,7 +29,7 @@ const dbActions: Action[] = [ ) : convertArrayOfStringsToContent(["No audit logs found."]); - showModal({ + openModal({ header: `Audit log of ${params.owner}/${params.db}`, content, }); diff --git a/agdb_studio/src/composables/db/dbDetails.spec.ts b/agdb_studio/src/composables/db/dbDetails.spec.ts new file mode 100644 index 00000000..6629bace --- /dev/null +++ b/agdb_studio/src/composables/db/dbDetails.spec.ts @@ -0,0 +1,87 @@ +import { describe, beforeEach, vi, it, expect } from "vitest"; +import { useDbDetails, type DbDetailsParams } from "./dbDetails"; +import { db_user_list, db_user_add, db_user_remove } from "@/tests/apiMock"; +import { ref } from "vue"; +import { useDbUsersStore } from "./dbUsersStore"; +import useModal from "@/composables/modal/modal"; +import { useContentInputs } from "../content/inputs"; +import { KEY_MODAL } from "../modal/constants"; + +const dbParams = ref({ + db: "testDb", + owner: "testOwner", + role: "admin", +}); +const { fetchDbUsers } = useDbUsersStore(); +const { modalIsVisible, onConfirm, closeModal } = useModal(); +const { setInputValue, clearAllInputs } = useContentInputs(); + +describe("dbDetails", () => { + beforeEach(() => { + vi.clearAllMocks(); + closeModal(); + clearAllInputs(); + }); + it("should get user list", async () => { + db_user_list.mockResolvedValue({ + data: [ + { + username: "testUser", + role: "read", + }, + { + username: "testOwner", + role: "admin", + }, + ], + }); + const { users } = useDbDetails(dbParams); + await fetchDbUsers(dbParams.value); + expect(users.value).toHaveLength(2); + expect(users.value?.[0].username).toBe("testUser"); + }); + it("should get db name", () => { + const { dbName } = useDbDetails(dbParams); + expect(dbName.value).toBe("testOwner/testDb"); + }); + it("should get can edit users", () => { + const { canEditUsers } = useDbDetails(dbParams); + expect(canEditUsers.value).toBe(true); + }); + it("should remove user", () => { + const { handleRemoveUser } = useDbDetails(dbParams); + handleRemoveUser("testUser"); + expect(modalIsVisible.value).toBe(true); + onConfirm.value?.(); + expect(db_user_remove).toHaveBeenCalledOnce(); + }); + it("should add user", () => { + const { handleAddUser } = useDbDetails(dbParams); + handleAddUser(); + expect(modalIsVisible.value).toBe(true); + setInputValue(KEY_MODAL, "username", "testUser"); + onConfirm.value?.(); + expect(db_user_add).toHaveBeenCalled(); + }); + it("should not add user if role is not admin", () => { + const { handleAddUser } = useDbDetails( + ref({ ...dbParams.value, role: "read" }), + ); + handleAddUser(); + expect(modalIsVisible.value).toBe(false); + }); + it("should not remove user if role is not admin", () => { + const { handleRemoveUser } = useDbDetails( + ref({ ...dbParams.value, role: "read" }), + ); + handleRemoveUser("testUser"); + expect(modalIsVisible.value).toBe(false); + }); + it("should not add user if no username", () => { + const { handleAddUser } = useDbDetails(dbParams); + handleAddUser(); + expect(modalIsVisible.value).toBe(true); + onConfirm.value?.(); + expect(db_user_add).not.toHaveBeenCalled(); + }); +}); diff --git a/agdb_studio/src/composables/db/dbDetails.ts b/agdb_studio/src/composables/db/dbDetails.ts new file mode 100644 index 00000000..2f93f46d --- /dev/null +++ b/agdb_studio/src/composables/db/dbDetails.ts @@ -0,0 +1,122 @@ +import type { ServerDatabase } from "agdb_api/dist/openapi"; +import { useDbStore, type DbIdentification } from "./dbStore"; +import { useDbUsersStore } from "./dbUsersStore"; +import { useContentInputs } from "../content/inputs"; +import { computed, type Ref } from "vue"; +import useModal from "../modal/modal"; +import { KEY_MODAL } from "../modal/constants"; +import { EMPHESIZED_CLASSNAME } from "../content/utils"; + +export type DbDetailsParams = DbIdentification & Pick; + +const { getDbUsers, fetchDbUsers, removeUser, addUser, isDbRoleType } = + useDbUsersStore(); +const { getInputValue } = useContentInputs(); + +const { openModal } = useModal(); +const { getDbName } = useDbStore(); + +export const useDbDetails = (dbParams: Ref) => { + const users = computed(() => { + return getDbUsers(dbParams.value); + }); + + const dbName = computed(() => { + return getDbName(dbParams.value); + }); + + const canEditUsers = computed(() => { + return dbParams.value.role === "admin"; + }); + + const handleRemoveUser = (username: string) => { + if (!canEditUsers.value) { + return; + } + openModal({ + header: "Remove user", + content: [ + { + paragraph: [ + { text: "Are you sure you want to remove user " }, + { text: username, className: EMPHESIZED_CLASSNAME }, + { text: " from database " }, + { text: dbName.value, className: EMPHESIZED_CLASSNAME }, + { text: "?" }, + ], + }, + ], + + onConfirm: () => { + removeUser({ + owner: dbParams.value.owner, + db: dbParams.value.db, + username: username, + }).then(() => { + fetchDbUsers(dbParams.value); + }); + }, + }); + }; + + const handleAddUser = () => { + if (!canEditUsers.value) { + return; + } + openModal({ + header: "Add user", + content: [ + { + paragraph: [ + { text: "Add user to database " }, + { text: dbName.value, className: EMPHESIZED_CLASSNAME }, + ], + }, + { + input: { + key: "username", + label: "Username", + type: "text", + autofocus: true, + }, + }, + { + input: { + key: "role", + label: "Role", + type: "select", + options: [ + { value: "admin", label: "Admin" }, + { value: "write", label: "Read/Write" }, + { value: "read", label: "Read Only" }, + ], + defaultValue: "write", + }, + }, + ], + onConfirm: () => { + const username = getInputValue( + KEY_MODAL, + "username", + )?.toString(); + const db_role = getInputValue(KEY_MODAL, "role")?.toString(); + + if (username?.length && db_role && isDbRoleType(db_role)) { + addUser({ ...dbParams.value, username, db_role }).then( + () => { + fetchDbUsers(dbParams.value); + }, + ); + } + }, + }); + }; + + return { + users, + dbName, + canEditUsers, + handleRemoveUser, + handleAddUser, + }; +}; diff --git a/agdb_studio/src/composables/db/dbStore.ts b/agdb_studio/src/composables/db/dbStore.ts index 75508c2e..37530e76 100644 --- a/agdb_studio/src/composables/db/dbStore.ts +++ b/agdb_studio/src/composables/db/dbStore.ts @@ -25,10 +25,17 @@ const addDatabase = async ({ name, db_type }: AddDatabaseProps) => { client.value?.db_add({ owner: username.value, db: name, db_type }); }; +export type DbIdentification = Pick; + +const getDbName = (db: DbIdentification) => { + return `${db.owner}/${db.db}`; +}; + export const useDbStore = () => { return { databases, fetchDatabases, addDatabase, + getDbName, }; }; diff --git a/agdb_studio/src/composables/db/dbUsersStore.spec.ts b/agdb_studio/src/composables/db/dbUsersStore.spec.ts new file mode 100644 index 00000000..e5b61258 --- /dev/null +++ b/agdb_studio/src/composables/db/dbUsersStore.spec.ts @@ -0,0 +1,69 @@ +import { describe, beforeEach, vi, it, expect } from "vitest"; +import { useDbUsersStore } from "./dbUsersStore"; +import { db_user_list, db_user_add, db_user_remove } from "@/tests/apiMock"; + +const dbIdentification = { + db: "testDb", + owner: "testOwner", +}; +const { + getDbUsers, + fetchDbUsers, + addUser, + removeUser, + clearDbUsers, + clearAllDbUsers, + isDbRoleType, +} = useDbUsersStore(); +describe("dbUsers", () => { + beforeEach(() => { + vi.clearAllMocks(); + clearAllDbUsers(); + }); + it("should fetch and get users", async () => { + db_user_list.mockResolvedValue({ + data: [ + { + username: "testUser", + role: "read", + }, + { + username: "testOwner", + role: "admin", + }, + ], + }); + expect(getDbUsers(dbIdentification)).toBeUndefined(); + + await fetchDbUsers(dbIdentification); + expect(db_user_list).toHaveBeenCalledOnce(); + const users = getDbUsers(dbIdentification); + expect(users).toHaveLength(2); + expect(users?.[0].username).toBe("testUser"); + }); + it("should add user", async () => { + await addUser({ + ...dbIdentification, + username: "testUser", + db_role: "read", + }); + expect(db_user_add).toHaveBeenCalledOnce(); + }); + it("should remove user", async () => { + await removeUser({ + ...dbIdentification, + username: "testUser", + }); + expect(db_user_remove).toHaveBeenCalledOnce(); + }); + it("should clear users", () => { + clearDbUsers(dbIdentification); + expect(getDbUsers(dbIdentification)).toBeUndefined(); + }); + it("should check role type", () => { + expect(isDbRoleType("read")).toBe(true); + expect(isDbRoleType("write")).toBe(true); + expect(isDbRoleType("admin")).toBe(true); + expect(isDbRoleType("other")).toBe(false); + }); +}); diff --git a/agdb_studio/src/composables/db/dbUsersStore.ts b/agdb_studio/src/composables/db/dbUsersStore.ts new file mode 100644 index 00000000..71666227 --- /dev/null +++ b/agdb_studio/src/composables/db/dbUsersStore.ts @@ -0,0 +1,57 @@ +import { client } from "@/services/api.service"; +import type { DbUser, DbUserRole } from "agdb_api/dist/openapi"; +import { ref } from "vue"; +import { useDbStore, type DbIdentification } from "./dbStore"; + +const { getDbName } = useDbStore(); + +const dbUsers = ref(new Map()); + +const fetchDbUsers = async (params: DbIdentification) => { + client.value?.db_user_list(params).then((users) => { + dbUsers.value.set(getDbName(params), users.data); + }); +}; + +const getDbUsers = (params: DbIdentification) => { + return dbUsers.value.get(getDbName(params)); +}; + +const clearDbUsers = (params: DbIdentification) => { + dbUsers.value.delete(getDbName(params)); +}; + +const clearAllDbUsers = () => { + dbUsers.value.clear(); +}; + +type AddUserProps = { + username: string; + db_role: DbUserRole; +} & DbIdentification; +const addUser = async (params: AddUserProps) => { + client.value?.db_user_add(params); +}; + +type RemoveUserProps = { + username: string; +} & DbIdentification; +const removeUser = async (params: RemoveUserProps) => { + client.value?.db_user_remove(params); +}; + +const isDbRoleType = (role: string): role is DbUserRole => { + return ["read", "write", "admin"].includes(role); +}; + +export const useDbUsersStore = () => { + return { + getDbUsers, + fetchDbUsers, + addUser, + removeUser, + clearDbUsers, + clearAllDbUsers, + isDbRoleType, + }; +}; diff --git a/agdb_studio/src/composables/modal/modal.spec.ts b/agdb_studio/src/composables/modal/modal.spec.ts index cc1143e5..0b8be46f 100644 --- a/agdb_studio/src/composables/modal/modal.spec.ts +++ b/agdb_studio/src/composables/modal/modal.spec.ts @@ -5,27 +5,27 @@ import { useContentInputs } from "../content/inputs"; import { KEY_MODAL } from "./constants"; describe("Modal", () => { - const { showModal, hideModal } = useModal(); + const { openModal, closeModal } = useModal(); beforeEach(() => { - hideModal(); + closeModal(); }); it("shows a modal", () => { - showModal({ + openModal({ header: "Test Header", content: convertArrayOfStringsToContent(["Test Body"]), }); expect(useModal().modalIsVisible.value).toBe(true); }); it("hides a modal", () => { - showModal({ + openModal({ header: "Test Header", content: convertArrayOfStringsToContent(["Test Body"]), }); - hideModal(); + closeModal(); expect(useModal().modalIsVisible.value).toBe(false); }); it("shows a modal with custom buttons", () => { - showModal({ + openModal({ header: "Test Header", content: convertArrayOfStringsToContent(["Test Body"]), buttons: [ @@ -41,7 +41,7 @@ describe("Modal", () => { }); it("calls onConfirm when confirm button is clicked and hides the modal", () => { const onConfirm = vi.fn(); - showModal({ + openModal({ header: "Test Header", content: convertArrayOfStringsToContent(["Test Body"]), onConfirm, @@ -51,7 +51,7 @@ describe("Modal", () => { expect(useModal().modalIsVisible.value).toBe(false); }); it("sets default if no header or content is provided", () => { - showModal({}); + openModal({}); expect(useModal().modal.header).toBe(""); expect(useModal().modal.content).toHaveLength(0); }); @@ -59,7 +59,7 @@ describe("Modal", () => { const { getInputValue, setInputValue } = useContentInputs(); setInputValue(KEY_MODAL, "test", "test"); expect(getInputValue(KEY_MODAL, "test")).toBe(undefined); - showModal({ + openModal({ header: "Test Header", content: [ { diff --git a/agdb_studio/src/composables/modal/modal.ts b/agdb_studio/src/composables/modal/modal.ts index 9514b5e7..38b36df9 100644 --- a/agdb_studio/src/composables/modal/modal.ts +++ b/agdb_studio/src/composables/modal/modal.ts @@ -13,7 +13,7 @@ const modalIsVisible = ref(false); const onConfirm = ref<() => void>(); -const hideModal = (): void => { +const closeModal = (): void => { modal.header = ""; modal.content = []; modalIsVisible.value = false; @@ -27,7 +27,7 @@ const buttons = computed(() => { { className: "button", text: "Close", - action: hideModal, + action: closeModal, }, ]; if (onConfirm.value) { @@ -36,7 +36,7 @@ const buttons = computed(() => { text: "Confirm", action: () => { onConfirm.value?.(); - hideModal(); + closeModal(); }, type: "submit", }); @@ -51,7 +51,7 @@ type ShowModalProps = { buttons?: Button[]; }; -const showModal = ({ +const openModal = ({ header, content, onConfirm: onConfirmFn, @@ -62,7 +62,7 @@ const showModal = ({ clearInputs(KEY_MODAL); content?.forEach((c) => { if (c.input) { - addInput(KEY_MODAL, c.input.key, ref()); + addInput(KEY_MODAL, c.input.key, ref(c.input.defaultValue)); } }); @@ -76,8 +76,8 @@ export default function useModal() { modal, buttons, modalIsVisible, - hideModal, - showModal, + closeModal, + openModal, onConfirm, }; } diff --git a/agdb_studio/src/composables/table/tableConfig.ts b/agdb_studio/src/composables/table/tableConfig.ts index 076ceace..938ac7f0 100644 --- a/agdb_studio/src/composables/table/tableConfig.ts +++ b/agdb_studio/src/composables/table/tableConfig.ts @@ -8,7 +8,7 @@ const tables = ref>>( type AddTableProps = { name: Symbol | string; columns: Column[]; - rowDetailsComponent?: string; + rowDetailsComponent?: AsyncComponent; }; const addTable = ({ diff --git a/agdb_studio/src/composables/table/types.ts b/agdb_studio/src/composables/table/types.ts index bb3963b2..9dada369 100644 --- a/agdb_studio/src/composables/table/types.ts +++ b/agdb_studio/src/composables/table/types.ts @@ -8,7 +8,7 @@ export type Column = { cellClass?: string | ((row: T) => string); sortable?: boolean; filterable?: boolean; - cellComponent?: string | ((row: T) => string); + cellComponent?: AsyncComponent | ((row: T) => AsyncComponent); valueFormatter?: (value: TCellType) => TCellType; actions?: Action[]; }; @@ -17,5 +17,5 @@ export type Table = { name: Symbol | string; columns: Map>; data?: Map; - rowDetailsComponent?: string; + rowDetailsComponent?: AsyncComponent; }; diff --git a/agdb_studio/src/composables/table/utils.spec.ts b/agdb_studio/src/composables/table/utils.spec.ts index 5129240f..5b598047 100644 --- a/agdb_studio/src/composables/table/utils.spec.ts +++ b/agdb_studio/src/composables/table/utils.spec.ts @@ -1,4 +1,5 @@ import { dateFormatter } from "./utils"; +import { describe, it, expect } from "vitest"; describe("utils", () => { describe("dateFormatter", () => { diff --git a/agdb_studio/src/tests/apiMock.ts b/agdb_studio/src/tests/apiMock.ts index ac64ef71..1abe4bea 100644 --- a/agdb_studio/src/tests/apiMock.ts +++ b/agdb_studio/src/tests/apiMock.ts @@ -14,6 +14,9 @@ export const db_optimize = vi.fn(); export const db_audit = vi.fn().mockResolvedValue({ data: [] }); export const db_copy = vi.fn(); export const db_rename = vi.fn(); +export const db_user_list = vi.fn(); +export const db_user_add = vi.fn(); +export const db_user_remove = vi.fn(); export const client = vi.fn().mockResolvedValue({ login: vi.fn().mockResolvedValue("token"), @@ -42,6 +45,9 @@ export const client = vi.fn().mockResolvedValue({ db_audit, db_copy, db_rename, + db_user_list, + db_user_add, + db_user_remove, }); vi.mock("agdb_api", () => { return { diff --git a/agdb_studio/src/types/asyncComponents.d.ts b/agdb_studio/src/types/asyncComponents.d.ts new file mode 100644 index 00000000..d58af1c5 --- /dev/null +++ b/agdb_studio/src/types/asyncComponents.d.ts @@ -0,0 +1 @@ +type AsyncComponent = "DbDetails"; diff --git a/agdb_studio/src/types/base.d.ts b/agdb_studio/src/types/base.d.ts index 7c252b5e..798c0d50 100644 --- a/agdb_studio/src/types/base.d.ts +++ b/agdb_studio/src/types/base.d.ts @@ -13,7 +13,17 @@ type Paragraph = { style?: StyleObject; className?: string; }; -type InputType = "text" | "number" | "password" | "email" | "checkbox"; +type InputType = + | "text" + | "number" + | "password" + | "email" + | "checkbox" + | "select"; +type OptionType = { + value: string; + label: string; +}; type Input = { key: string; label: string; @@ -21,10 +31,13 @@ type Input = { style?: StyleObject; className?: string; autofocus?: boolean; + options?: OptionType[]; + defaultValue?: string | number | boolean; }; + type Content = { paragraph?: Paragraph[]; - component?: string; + component?: AsyncComponent; input?: Input; }; diff --git a/agdb_studio/src/utils/asyncComponents.spec.ts b/agdb_studio/src/utils/asyncComponents.spec.ts new file mode 100644 index 00000000..ab09bb7f --- /dev/null +++ b/agdb_studio/src/utils/asyncComponents.spec.ts @@ -0,0 +1,9 @@ +import { describe, it, expect } from "vitest"; +import { getAsyncComponent } from "./asyncComponents"; + +describe("asyncComponents", () => { + it("should return component", () => { + const component = getAsyncComponent("DbDetails"); + expect(component).toBeDefined(); + }); +}); diff --git a/agdb_studio/src/utils/asyncComponents.ts b/agdb_studio/src/utils/asyncComponents.ts new file mode 100644 index 00000000..373c02d2 --- /dev/null +++ b/agdb_studio/src/utils/asyncComponents.ts @@ -0,0 +1,13 @@ +import { defineAsyncComponent } from "vue"; + +const asyncComponents: Record< + AsyncComponent, + ReturnType +> = { + DbDetails: defineAsyncComponent( + () => import("@/components/db/DbDetails.vue"), + ), +}; +export const getAsyncComponent = (componentName: AsyncComponent) => { + return asyncComponents[componentName]; +};