diff --git a/mathesar/urls.py b/mathesar/urls.py index 1daba441f7..004d216c42 100644 --- a/mathesar/urls.py +++ b/mathesar/urls.py @@ -57,11 +57,15 @@ path('shares/tables//', views.shared_table, name='shared_table'), path('shares/explorations//', views.shared_query, name='shared_query'), path('databases/', views.databases, name='databases'), - path('db//', views.schemas, name='schemas'), path('i18n/', include('django.conf.urls.i18n')), re_path( - r'^db/(?P\w+)/(?P\w+)/', + r'^db/(?P\w+)/schemas/(?P\w+)/', views.schemas_home, name='schema_home' ), + re_path( + r'^db/(?P\w+)/((schemas|settings)/)?', + views.schemas, + name='schemas' + ), ] diff --git a/mathesar_ui/.eslintrc.cjs b/mathesar_ui/.eslintrc.cjs index 6b30ababfa..74e522d2c4 100644 --- a/mathesar_ui/.eslintrc.cjs +++ b/mathesar_ui/.eslintrc.cjs @@ -48,6 +48,9 @@ module.exports = { 'array-bracket-spacing': 'off', 'no-restricted-syntax': 0, '@typescript-eslint/require-await': 'off', + // TODO_BETA: Add following eslint rules + // '@typescript-eslint/consistent-type-imports': 'error', + // 'no-duplicate-imports': 'error', 'class-methods-use-this': 'off', 'no-multiple-empty-lines': 1, 'import/order': [ diff --git a/mathesar_ui/src/App.svelte b/mathesar_ui/src/App.svelte index a0b1812e01..d4317163e0 100644 --- a/mathesar_ui/src/App.svelte +++ b/mathesar_ui/src/App.svelte @@ -56,15 +56,15 @@ --color-text-muted: #6b7280; --color-substring-match: rgb(254, 221, 72); --color-substring-match-light: rgba(254, 221, 72, 0.2); - --text-size-xx-small: var(--size-xx-small); // 8px - --text-size-x-small: var(--size-x-small); // 10px - --text-size-small: var(--size-small); // 12px - --text-size-base: var(--size-base); // 14px - --text-size-large: var(--size-large); // 16px - --text-size-x-large: var(--size-x-large); // 18px - --text-size-xx-large: var(--size-xx-large); // 20px - --text-size-ultra-large: var(--size-ultra-large); // 24px - --text-size-super-ultra-large: var(--size-super-ultra-large); // 32px + --text-size-xx-small: var(--size-xx-small); + --text-size-x-small: var(--size-x-small); + --text-size-small: var(--size-small); + --text-size-base: var(--size-base); + --text-size-large: var(--size-large); + --text-size-x-large: var(--size-x-large); + --text-size-xx-large: var(--size-xx-large); + --text-size-ultra-large: var(--size-ultra-large); + --text-size-super-ultra-large: var(--size-super-ultra-large); --modal-z-index: 50; --modal-record-selector-z-index: 50; diff --git a/mathesar_ui/src/api/rpc/collaborators.ts b/mathesar_ui/src/api/rpc/collaborators.ts new file mode 100644 index 0000000000..5c89d2829f --- /dev/null +++ b/mathesar_ui/src/api/rpc/collaborators.ts @@ -0,0 +1,41 @@ +import { rpcMethodTypeContainer } from '@mathesar/packages/json-rpc-client-builder'; + +import type { RawConfiguredRole } from './configured_roles'; +import type { RawDatabase } from './databases'; + +export interface RawCollaborator { + id: number; + user_id: number; + database_id: RawDatabase['id']; + configured_role_id: RawConfiguredRole['id']; +} + +export const collaborators = { + list: rpcMethodTypeContainer< + { + database_id: RawDatabase['id']; + }, + Array + >(), + add: rpcMethodTypeContainer< + { + database_id: RawDatabase['id']; + user_id: number; + configured_role_id: RawConfiguredRole['id']; + }, + RawCollaborator + >(), + set_role: rpcMethodTypeContainer< + { + collaborator_id: RawCollaborator['id']; + configured_role_id: RawConfiguredRole['id']; + }, + RawCollaborator + >(), + delete: rpcMethodTypeContainer< + { + collaborator_id: RawCollaborator['id']; + }, + void + >(), +}; diff --git a/mathesar_ui/src/api/rpc/configured_roles.ts b/mathesar_ui/src/api/rpc/configured_roles.ts index 351452b2f6..bb30bae9ec 100644 --- a/mathesar_ui/src/api/rpc/configured_roles.ts +++ b/mathesar_ui/src/api/rpc/configured_roles.ts @@ -1,10 +1,10 @@ import { rpcMethodTypeContainer } from '@mathesar/packages/json-rpc-client-builder'; -import type { Server } from './servers'; +import type { RawServer } from './servers'; -export interface ConfiguredRole { +export interface RawConfiguredRole { id: number; - server_id: Server['id']; + server_id: RawServer['id']; name: string; } @@ -12,30 +12,30 @@ export interface ConfiguredRole { export const configured_roles = { list: rpcMethodTypeContainer< { - server_id: ConfiguredRole['server_id']; + server_id: RawConfiguredRole['server_id']; }, - Array + Array >(), add: rpcMethodTypeContainer< { - server_id: ConfiguredRole['server_id']; - name: ConfiguredRole['name']; + server_id: RawConfiguredRole['server_id']; + name: RawConfiguredRole['name']; password: string; }, - ConfiguredRole + RawConfiguredRole >(), delete: rpcMethodTypeContainer< { - configured_role_id: ConfiguredRole['id']; + configured_role_id: RawConfiguredRole['id']; }, void >(), set_password: rpcMethodTypeContainer< { - configured_role_id: ConfiguredRole['id']; + configured_role_id: RawConfiguredRole['id']; password: string; }, void diff --git a/mathesar_ui/src/api/rpc/database_setup.ts b/mathesar_ui/src/api/rpc/database_setup.ts index 41f42ef584..81c5a0f93b 100644 --- a/mathesar_ui/src/api/rpc/database_setup.ts +++ b/mathesar_ui/src/api/rpc/database_setup.ts @@ -1,8 +1,8 @@ import { rpcMethodTypeContainer } from '@mathesar/packages/json-rpc-client-builder'; -import type { ConfiguredRole } from './configured_roles'; -import type { DatabaseResponse } from './databases'; -import type { Server } from './servers'; +import type { RawConfiguredRole } from './configured_roles'; +import type { RawDatabase } from './databases'; +import type { RawServer } from './servers'; export const sampleDataOptions = [ 'library_management', @@ -12,16 +12,16 @@ export const sampleDataOptions = [ export type SampleDataSchemaIdentifier = (typeof sampleDataOptions)[number]; export interface DatabaseConnectionResult { - server: Server; - database: DatabaseResponse; - configured_role: ConfiguredRole; + server: RawServer; + database: RawDatabase; + configured_role: RawConfiguredRole; } // eslint-disable-next-line @typescript-eslint/naming-convention export const database_setup = { create_new: rpcMethodTypeContainer< { - database: DatabaseResponse['name']; + database: RawDatabase['name']; sample_data?: SampleDataSchemaIdentifier[]; }, DatabaseConnectionResult @@ -29,10 +29,10 @@ export const database_setup = { connect_existing: rpcMethodTypeContainer< { - host: Server['host']; - port: Server['port']; - database: DatabaseResponse['name']; - role: ConfiguredRole['name']; + host: RawServer['host']; + port: RawServer['port']; + database: RawDatabase['name']; + role: RawConfiguredRole['name']; password: string; sample_data?: SampleDataSchemaIdentifier[]; }, diff --git a/mathesar_ui/src/api/rpc/databases.ts b/mathesar_ui/src/api/rpc/databases.ts index 1fa2b32e37..10e9a11628 100644 --- a/mathesar_ui/src/api/rpc/databases.ts +++ b/mathesar_ui/src/api/rpc/databases.ts @@ -1,44 +1,18 @@ import { rpcMethodTypeContainer } from '@mathesar/packages/json-rpc-client-builder'; -import type { Server } from './servers'; +import type { RawServer } from './servers'; -export interface DatabaseResponse { +export interface RawDatabase { id: number; name: string; - server_id: Server['id']; -} - -/** - * TODO_BETA: Modify after store discussion is resolved - */ -export class Database implements DatabaseResponse { - readonly id: number; - - readonly name: string; - - readonly server_id: number; - - readonly server_host: string; - - readonly server_port: number; - - constructor(databaseResponse: DatabaseResponse, server: Server) { - this.id = databaseResponse.id; - this.name = databaseResponse.name; - if (databaseResponse.server_id !== server.id) { - throw new Error('Server ids do not match'); - } - this.server_id = databaseResponse.server_id; - this.server_host = server.host; - this.server_port = server.port; - } + server_id: RawServer['id']; } export const databases = { list: rpcMethodTypeContainer< { - server_id?: DatabaseResponse['server_id']; + server_id?: RawDatabase['server_id']; }, - Array + Array >(), }; diff --git a/mathesar_ui/src/api/rpc/index.ts b/mathesar_ui/src/api/rpc/index.ts index 9e12fd779a..a48b958e06 100644 --- a/mathesar_ui/src/api/rpc/index.ts +++ b/mathesar_ui/src/api/rpc/index.ts @@ -2,12 +2,14 @@ import Cookies from 'js-cookie'; import { buildRpcApi } from '@mathesar/packages/json-rpc-client-builder'; +import { collaborators } from './collaborators'; import { columns } from './columns'; import { configured_roles } from './configured_roles'; import { constraints } from './constraints'; import { database_setup } from './database_setup'; import { databases } from './databases'; import { records } from './records'; +import { roles } from './roles'; import { schemas } from './schemas'; import { servers } from './servers'; import { tables } from './tables'; @@ -17,10 +19,12 @@ export const api = buildRpcApi({ endpoint: '/api/rpc/v0/', getHeaders: () => ({ 'X-CSRFToken': Cookies.get('csrftoken') }), methodTree: { + collaborators, configured_roles, database_setup, databases, records, + roles, schemas, servers, tables, diff --git a/mathesar_ui/src/api/rpc/roles.ts b/mathesar_ui/src/api/rpc/roles.ts new file mode 100644 index 0000000000..82bbb2366c --- /dev/null +++ b/mathesar_ui/src/api/rpc/roles.ts @@ -0,0 +1,56 @@ +import { rpcMethodTypeContainer } from '@mathesar/packages/json-rpc-client-builder'; + +import type { RawDatabase } from './databases'; + +export interface RawRoleMember { + oid: number; + admin: boolean; +} + +export interface RawRole { + oid: number; + name: string; + super: boolean; + inherits: boolean; + create_role: boolean; + create_db: boolean; + login: boolean; + description?: string; + members?: RawRoleMember[]; +} + +export const roles = { + list: rpcMethodTypeContainer< + { + database_id: RawDatabase['id']; + }, + Array + >(), + + add: rpcMethodTypeContainer< + { + database_id: RawDatabase['id']; + rolename: RawRole['name']; + login: boolean; + password?: string; + }, + RawRole + >(), + + set_members: rpcMethodTypeContainer< + { + database_id: RawDatabase['id']; + role_oid: RawRole['oid']; + members: RawRole['oid'][]; + }, + RawRole + >(), + + delete: rpcMethodTypeContainer< + { + database_id: RawDatabase['id']; + role_oid: RawRole['oid']; + }, + void + >(), +}; diff --git a/mathesar_ui/src/api/rpc/servers.ts b/mathesar_ui/src/api/rpc/servers.ts index eaa9faec31..f92a29bebb 100644 --- a/mathesar_ui/src/api/rpc/servers.ts +++ b/mathesar_ui/src/api/rpc/servers.ts @@ -1,11 +1,11 @@ import { rpcMethodTypeContainer } from '@mathesar/packages/json-rpc-client-builder'; -export interface Server { +export interface RawServer { id: number; host: string; port: number; } export const servers = { - list: rpcMethodTypeContainer>(), + list: rpcMethodTypeContainer>(), }; diff --git a/mathesar_ui/src/component-library/common/styles/variables.scss b/mathesar_ui/src/component-library/common/styles/variables.scss index 4e31fd79b5..48e55adc2d 100644 --- a/mathesar_ui/src/component-library/common/styles/variables.scss +++ b/mathesar_ui/src/component-library/common/styles/variables.scss @@ -34,6 +34,7 @@ --slate-700: #424952; --slate-800: #25292e; + --sand-50: #fcfbf8; --sand-100: #f9f8f6; --sand-200: #efece7; --sand-300: #e2dcd4; diff --git a/mathesar_ui/src/component-library/common/utils/ImmutableMap.ts b/mathesar_ui/src/component-library/common/utils/ImmutableMap.ts index ebd2697940..2e68c25e7c 100644 --- a/mathesar_ui/src/component-library/common/utils/ImmutableMap.ts +++ b/mathesar_ui/src/component-library/common/utils/ImmutableMap.ts @@ -19,7 +19,7 @@ export default class ImmutableMap { * know. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any - private getNewInstance(...args: any[]): this { + protected getNewInstance(...args: any[]): this { // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any return new (this.constructor as any)(...args) as this; } @@ -134,6 +134,12 @@ export default class ImmutableMap { ); } + mapKeys(fn: (value: Value) => NewKey): ImmutableMap { + return new ImmutableMap( + [...this.values()].map((value) => [fn(value), value]), + ); + } + filterValues(fn: (value: Value) => boolean): ImmutableMap { return new ImmutableMap( [...this.entries()].filter(([, value]) => fn(value)), diff --git a/mathesar_ui/src/component-library/common/utils/SortedImmutableMap.ts b/mathesar_ui/src/component-library/common/utils/SortedImmutableMap.ts new file mode 100644 index 0000000000..8d51ef62eb --- /dev/null +++ b/mathesar_ui/src/component-library/common/utils/SortedImmutableMap.ts @@ -0,0 +1,22 @@ +import ImmutableMap from './ImmutableMap'; + +export default class SortedImmutableMap extends ImmutableMap< + Key, + Value +> { + sortFn; + + constructor( + sortFn: (j: Iterable<[Key, Value]>) => Iterable<[Key, Value]>, + i: Iterable<[Key, Value]> = [], + ) { + const sorted = sortFn(i); + super(sorted); + this.sortFn = sortFn; + } + + protected getNewInstance(...args: any[]): this { + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any + return new (this.constructor as any)(this.sortFn, ...args) as this; + } +} diff --git a/mathesar_ui/src/component-library/common/utils/__tests__/SortedImmutableMap.test.ts b/mathesar_ui/src/component-library/common/utils/__tests__/SortedImmutableMap.test.ts new file mode 100644 index 0000000000..c7a32a3248 --- /dev/null +++ b/mathesar_ui/src/component-library/common/utils/__tests__/SortedImmutableMap.test.ts @@ -0,0 +1,113 @@ +import SortedImmutableMap from '../SortedImmutableMap'; + +const sample = new SortedImmutableMap( + (v) => [...v].sort(([, a], [, b]) => a.localeCompare(b)), + [ + [2, 'b'], + [1, 'a'], + ], +); + +test('with', () => { + expect([...sample.with(2, 'b')]).toEqual([ + [1, 'a'], + [2, 'b'], + ]); + expect([...sample.with(2, 'c')]).toEqual([ + [1, 'a'], + [2, 'c'], + ]); + expect([...sample.with(3, 'c')]).toEqual([ + [1, 'a'], + [2, 'b'], + [3, 'c'], + ]); + expect([...sample.with(0, '')]).toEqual([ + [0, ''], + [1, 'a'], + [2, 'b'], + ]); +}); + +test('without', () => { + expect([...sample.without(2)]).toEqual([[1, 'a']]); + expect([...sample.without(3)]).toEqual([ + [1, 'a'], + [2, 'b'], + ]); +}); + +test('has', () => { + expect(sample.has(2)).toBe(true); + expect(sample.has(3)).toBe(false); +}); + +test('size', () => { + expect(sample.size).toBe(2); + expect(new SortedImmutableMap((v) => [...v].sort()).size).toBe(0); +}); + +test('withEntries', () => { + expect([ + ...sample.withEntries([ + [2, 'FOO'], + [3, 'c'], + ]), + ]).toEqual([ + [1, 'a'], + [3, 'c'], + [2, 'FOO'], + ]); + expect([...sample.withEntries([])]).toEqual([ + [1, 'a'], + [2, 'b'], + ]); + expect([ + ...sample.withEntries( + [ + [3, 'c'], + [2, 'FOO'], + ], + (a, b) => `${a}-${b}`, + ), + ]).toEqual([ + [1, 'a'], + [2, 'b-FOO'], + [3, 'c'], + ]); +}); + +test('mapValues', () => { + expect([...sample.mapValues((v) => `(${v})`)]).toEqual([ + [1, '(a)'], + [2, '(b)'], + ]); +}); + +test('coalesce', () => { + expect([...sample.coalesce(1, 'FOO')]).toEqual([ + [1, 'a'], + [2, 'b'], + ]); + expect([...sample.coalesce(3, 'FOO')]).toEqual([ + [1, 'a'], + [2, 'b'], + [3, 'FOO'], + ]); +}); + +test('coalesceEntries', () => { + expect([...sample.coalesceEntries([[1, 'FOO']])]).toEqual([ + [1, 'a'], + [2, 'b'], + ]); + expect([...sample.coalesceEntries([])]).toEqual([ + [1, 'a'], + [2, 'b'], + ]); + expect([...sample.coalesceEntries([[3, 'FOO']])]).toEqual([ + [1, 'a'], + [2, 'b'], + [3, 'FOO'], + ]); +}); diff --git a/mathesar_ui/src/component-library/common/utils/index.ts b/mathesar_ui/src/component-library/common/utils/index.ts index 24bc24a805..9520d453a0 100644 --- a/mathesar_ui/src/component-library/common/utils/index.ts +++ b/mathesar_ui/src/component-library/common/utils/index.ts @@ -3,6 +3,7 @@ export { default as CancellablePromise } from './CancellablePromise'; export { default as EventHandler } from './EventHandler'; export { default as ImmutableSet } from './ImmutableSet'; export { default as ImmutableMap } from './ImmutableMap'; +export { default as SortedImmutableMap } from './SortedImmutableMap'; export { default as WritableMap } from './WritableMap'; export { default as WritableSet } from './WritableSet'; export { default as dayjs } from './dayjs'; diff --git a/mathesar_ui/src/component-library/dropdown-menu/DropdownMenu.svelte b/mathesar_ui/src/component-library/dropdown-menu/DropdownMenu.svelte index 7c3850de7e..ddac9b8416 100644 --- a/mathesar_ui/src/component-library/dropdown-menu/DropdownMenu.svelte +++ b/mathesar_ui/src/component-library/dropdown-menu/DropdownMenu.svelte @@ -30,7 +30,7 @@ {/if} - + diff --git a/mathesar_ui/src/component-library/menu/Menu.scss b/mathesar_ui/src/component-library/menu/Menu.scss index 60c5c058dc..11b43435d8 100644 --- a/mathesar_ui/src/component-library/menu/Menu.scss +++ b/mathesar_ui/src/component-library/menu/Menu.scss @@ -1,9 +1,9 @@ .menu { - $spacing-x-default: 0.5em; - $spacing-y-default: 0.5em; + $padding-x-default: 0.5em; + $padding-y-default: 0.5em; display: inline-flex; flex-direction: column; - min-width: var(--min-width, 0); + min-width: var(--Menu__min-width, 0); .menu-heading { color: var(--slate-500); @@ -22,7 +22,7 @@ flex-grow: 1; align-items: center; background-color: var(--Menu__item-background, transparent); - padding: 0 var(--spacing-x, $spacing-x-default); + padding: 0 var(--Menu__padding-x, $padding-x-default); border-radius: var(--Menu__item-border-radius, 0); &.menu-item-link { @@ -52,8 +52,8 @@ flex-grow: 1; } > .cell:not(:empty) { - padding: var(--spacing-y, $spacing-y-default) - calc(var(--spacing-x, $spacing-x-default) / 2); + padding: var(--Menu__padding-y, $padding-y-default) + calc(var(--Menu__padding-x, $padding-x-default) / 2); } > .control, > .icon { @@ -81,14 +81,14 @@ &.has-icon { .menu-item > .icon { width: calc( - var(--Menu__icon-width) + var(--spacing-x, $spacing-x-default) + var(--Menu__icon-width) + var(--Menu__padding-x, $padding-x-default) ); } } &.has-control { .menu-item > .control { width: calc( - var(--Menu__control-width) + var(--spacing-x, $spacing-x-default) + var(--Menu__control-width) + var(--Menu__padding-x, $padding-x-default) ); } } diff --git a/mathesar_ui/src/components/AppHeader.svelte b/mathesar_ui/src/components/AppHeader.svelte index 8b58177a59..d12f8feaab 100644 --- a/mathesar_ui/src/components/AppHeader.svelte +++ b/mathesar_ui/src/components/AppHeader.svelte @@ -103,7 +103,7 @@ triggerAppearance="ghost" size="small" closeOnInnerClick={true} - menuStyle="--spacing-x: 0.3em;" + menuStyle="--Menu__padding-x: 0.3em;" >
diff --git a/mathesar_ui/src/components/AppSecondaryHeader.svelte b/mathesar_ui/src/components/AppSecondaryHeader.svelte index 72d242b98d..602ab86476 100644 --- a/mathesar_ui/src/components/AppSecondaryHeader.svelte +++ b/mathesar_ui/src/components/AppSecondaryHeader.svelte @@ -5,14 +5,9 @@ export let pageTitleAndMetaProps: ComponentProps; export let restrictWidth = true; - export let theme: 'dark' | 'light' | undefined = undefined; -
+
@@ -24,11 +19,7 @@
diff --git a/mathesar_ui/src/components/breadcrumb/DatabaseSelector.svelte b/mathesar_ui/src/components/breadcrumb/DatabaseSelector.svelte index 261a33469d..2de97b3e83 100644 --- a/mathesar_ui/src/components/breadcrumb/DatabaseSelector.svelte +++ b/mathesar_ui/src/components/breadcrumb/DatabaseSelector.svelte @@ -1,8 +1,8 @@ + +
+ +
+ + diff --git a/mathesar_ui/src/components/message-boxes/InfoBox.svelte b/mathesar_ui/src/components/message-boxes/InfoBox.svelte index 987e09f385..31382656e5 100644 --- a/mathesar_ui/src/components/message-boxes/InfoBox.svelte +++ b/mathesar_ui/src/components/message-boxes/InfoBox.svelte @@ -18,7 +18,4 @@ --MessageBox__border: solid 1px var(--sky-300); --MessageBox__icon-color: var(--sky-800); } - .info-box:not(.full-width) { - max-width: max-content; - } diff --git a/mathesar_ui/src/components/routing/EventfulRoute.svelte b/mathesar_ui/src/components/routing/EventfulRoute.svelte index 211235e1e2..602b28acc6 100644 --- a/mathesar_ui/src/components/routing/EventfulRoute.svelte +++ b/mathesar_ui/src/components/routing/EventfulRoute.svelte @@ -1,13 +1,15 @@ - + diff --git a/mathesar_ui/src/components/routing/MultiPathRoute.svelte b/mathesar_ui/src/components/routing/MultiPathRoute.svelte index 7be61b5539..abc4ecbe59 100644 --- a/mathesar_ui/src/components/routing/MultiPathRoute.svelte +++ b/mathesar_ui/src/components/routing/MultiPathRoute.svelte @@ -37,8 +37,8 @@ {#each paths as rp (rp.name)} setPath(rp, e.detail)} - on:unload={() => clearPath(rp)} + onLoad={(meta) => setPath(rp, meta)} + onUnload={() => clearPath(rp)} firstmatch /> {/each} diff --git a/mathesar_ui/src/components/routing/RouteObserver.svelte b/mathesar_ui/src/components/routing/RouteObserver.svelte index 105f5c5df8..55f214cb28 100644 --- a/mathesar_ui/src/components/routing/RouteObserver.svelte +++ b/mathesar_ui/src/components/routing/RouteObserver.svelte @@ -1,33 +1,21 @@ diff --git a/mathesar_ui/src/i18n/languages/en/dict.json b/mathesar_ui/src/i18n/languages/en/dict.json index ffc63e4e3d..4f6eab083f 100644 --- a/mathesar_ui/src/i18n/languages/en/dict.json +++ b/mathesar_ui/src/i18n/languages/en/dict.json @@ -9,6 +9,8 @@ "action_cannot_be_undone": "This action cannot be undone", "actions": "Actions", "add": "Add", + "add_child_roles": "Add Child Role(s)", + "add_collaborator": "Add Collaborator", "add_columns_to_exploration_empty_message": "This exploration does not contain any columns. Edit the exploration to add columns to it.", "add_filter": "Add Filter", "add_new_filter": "Add New Filter", @@ -32,6 +34,7 @@ "ascending": "Ascending", "ascending_id": "Ascending ID", "attempt_exploration_recovery": "Attempt Exploration recovery", + "authenticate": "Authenticate", "automatically": "Automatically", "base_table_exploration_help": "The base table is the table that is being explored and determines the columns that are available for exploration.", "based_on": "Based on", @@ -45,12 +48,20 @@ "cell": "Cell", "change_password": "Change Password", "check_for_updates": "Check for Updates", + "child_roles": "Child Roles", + "child_roles_saved_successfully": "The Child Roles have been successfully saved.", "choose_database": "Choose a Database", "choose_schema": "Choose a Schema", "choose_table_or_exploration": "Choose a Table or Exploration", "cleaning_up": "Cleaning up", "clear": "Clear", "clear_value": "Clear value", + "click_save_button_to_save_changes": "Click Save button to save changes.", + "collaborator": "Collaborator", + "collaborator_added_successfully": "Collaborator added successfully", + "collaborator_role_help": "This role determines permissions for this collaborator. Mathesar will use this PostgreSQL role for all database interactions by this user.", + "collaborator_role_updated_successfully": "Role updated successfully for collaborator.", + "collaborators": "Collaborators", "column": "Column", "column_added_number_of_times": "{count, plural, one {This column has been added once.} other {This column has been added {count} times.}}", "column_data_types": "Column Data Types", @@ -74,6 +85,9 @@ "columns_removed_from_table_added_to_target": "{count, plural, one {The column above will be removed from [tableName] and added to [targetTableName]} other {The columns above will be removed from [tableName] and added to [targetTableName]}}", "columns_to_extract": "Columns to Extract", "columns_to_move": "Columns to Move", + "configure_in_mathesar": "Configure in Mathesar", + "configure_password": "Configure Password", + "configure_value": "Configure ''{value}''", "confirm_and_create_table": "Confirm & create table", "confirm_delete_table": "To confirm the deletion of the [tableName] table, please enter the table name into the input field below.", "confirm_password": "Confirm Password", @@ -113,6 +127,7 @@ "create_new_pg_user": "Create a new PostgreSQL user", "create_new_schema": "Create New Schema", "create_record_from_search": "Create Record From Search Criteria", + "create_role": "Create Role", "create_schema": "Create Schema", "create_share_explorations_of_your_data": "Create and Share Explorations of Your Data", "create_table_move_columns": "Create Table and Move Columns", @@ -131,11 +146,13 @@ "database": "Database", "database_name": "Database Name", "database_not_found": "Database with id [connectionId] is not found.", + "database_permissions": "Database Permissions", "database_server_credentials": "Database server credentials", "database_type": "Database Type", "databases": "Databases", "databases_matching_search": "{count, plural, one {{count} database matches [searchValue]} other {{count} databases match [searchValue]}}", "days": "Days", + "db_server": "DB server", "default": "Default", "default_value": "Default Value", "delete": "Delete", @@ -148,6 +165,7 @@ "delete_connection_db_delete_info": "If you would like to delete the database too, you will need to do so from outside Mathesar by deleting it directly in PostgreSQL.", "delete_connection_info": "The database will not be deleted and will still be accessible outside Mathesar. You may choose to reconnect to it in the future; however, upon disconnecting you will lose Mathesar-specific metadata such as saved explorations, customized column display options, and customized record summary templates.", "delete_connection_with_name": "Delete [connectionName] Database Connection?", + "delete_database": "Delete Database", "delete_exploration": "Delete Exploration", "delete_import": "Delete Import", "delete_item": "Delete {item}", @@ -168,14 +186,20 @@ "disallow_null_values_help": "Enable this option to prevent null values in the column. Null values are empty values that are not the same as zero or an empty string.", "discard_changes": "Discard Changes", "disconnect": "Disconnect", + "disconnect_database": "Disconnect Database", "display_language": "Display Language", "display_name": "Display Name", "documentation_and_resources": "Documentation & Resources", + "drop_role": "Drop Role", + "drop_role_warning": "This role will be dropped on the database server and will not be available for any databases configured on the same server.", + "drop_role_with_identifier": "Drop Role [identifier]?", "edit": "Edit", + "edit_child_roles_for_parent": "Edit Child Roles for ''{parent}''", "edit_connection": "Edit Connection", "edit_connection_with_name": "Edit Connection: [connectionName]", "edit_exploration_attempt_recovery": "You can edit the exploration in the Data Explorer to attempt recovering it.", "edit_in_data_explorer": "Edit in Data Explorer", + "edit_role_for_collaborator_value": "Edit Role for ''{collaborator}''", "edit_schema": "Edit Schema", "edit_table": "Edit Table", "edit_table_with_name": "Edit [tableName] Table", @@ -248,6 +272,7 @@ "if_upgrade_succeeds_help": "If the upgrade succeeds, you will see that you're running the latest version.", "import": "Import", "import_from_file": "Import from a File", + "in_mathesar": "In Mathesar", "in_this_table": "In this table", "individual_permissions_admin_modify_warning": "Individual permissions cannot be modified for users with Admin access.", "inherited": "Inherited", @@ -293,6 +318,7 @@ "many_to_one": "Many to One", "many_to_one_link_description": "Multiple [baseTable] records can link to the same [targetTable] record.", "mathesar": "Mathesar", + "mathesar_user": "Mathesar User", "max_time_unit": "Max Time Unit", "milliseconds": "Milliseconds", "min_time_unit": "Min Time Unit", @@ -328,6 +354,7 @@ "new_user_name": "New user name", "new_version_available": "New Version Available", "newest_to_oldest_sort": "Newest-Oldest", + "no": "No", "no_actions_selected_record": "There are no actions to perform on the selected record(s).", "no_constraints": "No Constraints", "no_continue_without_summarization": "No, continue without summarizing", @@ -355,6 +382,7 @@ "number_of_matches_in_category": "{count, plural, one {{count} match for [searchValue] in [categoryName]} other {{count} matches for [searchValue] in [categoryName]}}", "old_password": "Old Password", "oldest_to_newest_sort": "Oldest-Newest", + "on_the_server": "On The Server", "one_column_from_base_is_required": "At least one column from the base table is required to add columns from linked tables.", "one_to_many": "One to Many", "one_to_many_link_desc": "One [baseTable] record can be linked from multiple [targetTable] records.", @@ -415,10 +443,14 @@ "release_notes": "Release Notes", "release_notes_and_upgrade_instructions": "Release Notes and Upgrade Instructions", "released_date": "Released {date}", + "remove": "Remove", + "remove_configuration": "Remove Configuration", + "remove_configuration_for_identifier": "Remove Configuration for [identifier]?", "remove_filters": "{count, plural, one {Remove Filter} other {Remove {count} Filters}}", "remove_grouping": "Remove Grouping", "remove_old_link_create_new": "Remove old link and create a new link?", "remove_sorting_type": "Remove {sortingType} Sorting", + "removing_role_configuration_warning": "Removing this configuration will prevent collaborators assigned to this role from accessing databases on server ''{server}''.", "rename_schema": "Rename [schemaName] Schema", "reset": "Reset", "restrict_to_unique": "Restrict to Unique", @@ -428,6 +460,16 @@ "retry": "Retry", "reuse_credentials_from_known_connection": "Reuse credentials from a known connection", "role": "Role", + "role_configuration": "Role Configuration", + "role_configuration_removed": "The role configuration has been removed successfully", + "role_configured_all_databases_in_server": "This role will be configured for all databases on DB Server ''{server}''", + "role_configured_successfully": "Role configured successfully", + "role_configured_successfully_new_password": "Role configured successfully with new password", + "role_created_successfully": "The Role has been created successfully", + "role_dropped_successfully": "The Role has been dropped successfully", + "role_has_no_child_roles": "The Role does not have any Child Roles", + "role_name": "Role Name", + "roles": "Roles", "row": "Row", "running_latest_version": "You are running the latest version", "sample_data_library_help": "Sample data from a fictional library.", @@ -465,11 +507,13 @@ "select_columns_to_hide": "Select Columns to Hide", "select_columns_view_properties": "Select a column to view it's properties.", "select_permission": "Select Permission", + "select_role": "Select Role", "select_table": "Select Table", "select_type": "Select Type", "select_user": "Select User", "set_constraint_name": "Set Constraint Name", "set_to": "Set to", + "settings": "Settings", "setup_connections_help": "Seems you haven't set up any databases. To use Mathesar, you'll need to connect one.", "share": "Share", "share_exploration": "Share Exploration", @@ -594,6 +638,7 @@ "while_upgrading": "While Upgrading", "why_is_this_needed": "Why is this needed?", "window_remains_open_mathesar_unusable": "This window will remain open but all features within Mathesar will be unusable.", + "yes": "Yes", "yes_summarize_as_list": "Yes, summarize as a list", "yesterday": "Yesterday" } diff --git a/mathesar_ui/src/icons/index.ts b/mathesar_ui/src/icons/index.ts index ed004cbb89..9048599c0f 100644 --- a/mathesar_ui/src/icons/index.ts +++ b/mathesar_ui/src/icons/index.ts @@ -105,6 +105,7 @@ export const iconAddFilter: IconProps = { data: faFilter }; export const iconAddNew: IconProps = { data: faPlus }; export const iconAddUser: IconProps = { data: faUserPlus }; export const iconConfigure: IconProps = { data: faCogs }; +export const iconConfigurePassword = { data: faKey }; export const iconConnectDatabase = { data: connectDatabaseIcon }; export const iconCopyMajor: IconProps = { data: faCopy }; /** TODO: use faBinary once it's available (via newer FontAwesome version) */ diff --git a/mathesar_ui/src/models/Collaborator.ts b/mathesar_ui/src/models/Collaborator.ts new file mode 100644 index 0000000000..2fcbefac21 --- /dev/null +++ b/mathesar_ui/src/models/Collaborator.ts @@ -0,0 +1,58 @@ +import { type Readable, type Writable, writable } from 'svelte/store'; + +import { api } from '@mathesar/api/rpc'; +import type { RawCollaborator } from '@mathesar/api/rpc/collaborators'; +import { CancellablePromise } from '@mathesar/component-library'; + +import type { ConfiguredRole } from './ConfiguredRole'; +import type { Database } from './Database'; + +export class Collaborator { + readonly id; + + readonly user_id; + + readonly _configured_role_id: Writable; + + get configured_role_id(): Readable { + return this._configured_role_id; + } + + readonly database; + + constructor(props: { database: Database; rawCollaborator: RawCollaborator }) { + this.id = props.rawCollaborator.id; + this.user_id = props.rawCollaborator.user_id; + this._configured_role_id = writable( + props.rawCollaborator.configured_role_id, + ); + this.database = props.database; + } + + setConfiguredRole( + configuredRoleId: ConfiguredRole['id'], + ): CancellablePromise { + const promise = api.collaborators + .set_role({ + collaborator_id: this.id, + configured_role_id: configuredRoleId, + }) + .run(); + + return new CancellablePromise( + (resolve, reject) => { + promise + .then((rawCollaborator) => { + this._configured_role_id.set(rawCollaborator.configured_role_id); + return resolve(this); + }, reject) + .catch(reject); + }, + () => promise.cancel(), + ); + } + + delete() { + return api.collaborators.delete({ collaborator_id: this.id }).run(); + } +} diff --git a/mathesar_ui/src/models/ConfiguredRole.ts b/mathesar_ui/src/models/ConfiguredRole.ts new file mode 100644 index 0000000000..3ebfaf5a46 --- /dev/null +++ b/mathesar_ui/src/models/ConfiguredRole.ts @@ -0,0 +1,34 @@ +import { api } from '@mathesar/api/rpc'; +import type { RawConfiguredRole } from '@mathesar/api/rpc/configured_roles'; + +import type { Database } from './Database'; + +export class ConfiguredRole { + readonly id: number; + + readonly name: string; + + readonly database: Database; + + constructor(props: { + database: Database; + rawConfiguredRole: RawConfiguredRole; + }) { + this.id = props.rawConfiguredRole.id; + this.name = props.rawConfiguredRole.name; + this.database = props.database; + } + + setPassword(password: string) { + return api.configured_roles + .set_password({ + configured_role_id: this.id, + password, + }) + .run(); + } + + delete() { + return api.configured_roles.delete({ configured_role_id: this.id }).run(); + } +} diff --git a/mathesar_ui/src/models/Database.ts b/mathesar_ui/src/models/Database.ts new file mode 100644 index 0000000000..be35d9b2e9 --- /dev/null +++ b/mathesar_ui/src/models/Database.ts @@ -0,0 +1,129 @@ +import { api } from '@mathesar/api/rpc'; +import type { RawDatabase } from '@mathesar/api/rpc/databases'; +import AsyncRpcApiStore from '@mathesar/stores/AsyncRpcApiStore'; +import { + CancellablePromise, + ImmutableMap, + SortedImmutableMap, +} from '@mathesar-component-library'; + +import { Collaborator } from './Collaborator'; +import { ConfiguredRole } from './ConfiguredRole'; +import { Role } from './Role'; +import type { Server } from './Server'; + +export class Database { + readonly id: number; + + readonly name: string; + + readonly server: Server; + + constructor(props: { server: Server; rawDatabase: RawDatabase }) { + this.id = props.rawDatabase.id; + this.name = props.rawDatabase.name; + this.server = props.server; + } + + constructConfiguredRolesStore() { + return new AsyncRpcApiStore(api.configured_roles.list, { + postProcess: (rawConfiguredRoles) => + new SortedImmutableMap( + (v) => [...v].sort(([, a], [, b]) => a.name.localeCompare(b.name)), + rawConfiguredRoles.map((rawConfiguredRole) => [ + rawConfiguredRole.id, + new ConfiguredRole({ database: this, rawConfiguredRole }), + ]), + ), + }); + } + + constructRolesStore() { + return new AsyncRpcApiStore(api.roles.list, { + postProcess: (rawRoles) => + new SortedImmutableMap( + (v) => [...v].sort(([, a], [, b]) => a.name.localeCompare(b.name)), + rawRoles.map((rawRole) => [ + rawRole.oid, + new Role({ database: this, rawRole }), + ]), + ), + }); + } + + constructCollaboratorsStore() { + return new AsyncRpcApiStore(api.collaborators.list, { + postProcess: (rawCollaborators) => + new ImmutableMap( + rawCollaborators.map((rawCollaborator) => [ + rawCollaborator.id, + new Collaborator({ database: this, rawCollaborator }), + ]), + ), + }); + } + + addCollaborator( + userId: number, + configuredRoleId: ConfiguredRole['id'], + ): CancellablePromise { + const promise = api.collaborators + .add({ + database_id: this.id, + user_id: userId, + configured_role_id: configuredRoleId, + }) + .run(); + + return new CancellablePromise( + (resolve, reject) => { + promise + .then( + (rawCollaborator) => + resolve( + new Collaborator({ + database: this, + rawCollaborator, + }), + ), + reject, + ) + .catch(reject); + }, + () => promise.cancel(), + ); + } + + addRole( + roleName: string, + login: boolean, + password?: string, + ): CancellablePromise { + const promise = api.roles + .add({ + database_id: this.id, + rolename: roleName, + login, + password, + }) + .run(); + + return new CancellablePromise( + (resolve, reject) => { + promise + .then( + (rawRole) => + resolve( + new Role({ + database: this, + rawRole, + }), + ), + reject, + ) + .catch(reject); + }, + () => promise.cancel(), + ); + } +} diff --git a/mathesar_ui/src/models/Role.ts b/mathesar_ui/src/models/Role.ts new file mode 100644 index 0000000000..97ebbe432f --- /dev/null +++ b/mathesar_ui/src/models/Role.ts @@ -0,0 +1,114 @@ +import type { Readable } from 'svelte/store'; + +import { api } from '@mathesar/api/rpc'; +import type { RawRole, RawRoleMember } from '@mathesar/api/rpc/roles'; +import { + CancellablePromise, + type ImmutableMap, + WritableMap, +} from '@mathesar-component-library'; + +import { ConfiguredRole } from './ConfiguredRole'; +import type { Database } from './Database'; + +function getMembersWritableMap(members: RawRole['members']) { + return new WritableMap((members ?? []).map((member) => [member.oid, member])); +} + +export class Role { + readonly oid: number; + + readonly name: string; + + readonly super: boolean; + + readonly inherits: boolean; + + readonly createRole: boolean; + + readonly createDb: boolean; + + readonly login: boolean; + + readonly description?: string; + + private _members; + + get members(): Readable> { + return this._members; + } + + readonly database: Database; + + constructor(props: { database: Database; rawRole: RawRole }) { + this.oid = props.rawRole.oid; + this.name = props.rawRole.name; + this.super = props.rawRole.super; + this.inherits = props.rawRole.inherits; + this.createRole = props.rawRole.create_role; + this.createDb = props.rawRole.create_db; + this.login = props.rawRole.login; + this.description = props.rawRole.description; + this._members = getMembersWritableMap(props.rawRole.members); + this.database = props.database; + } + + configure(password: string): CancellablePromise { + const promise = api.configured_roles + .add({ + server_id: this.database.server.id, + name: this.name, + password, + }) + .run(); + + return new CancellablePromise( + (resolve, reject) => { + promise + .then( + (rawConfiguredRole) => + resolve( + new ConfiguredRole({ + database: this.database, + rawConfiguredRole, + }), + ), + reject, + ) + .catch(reject); + }, + () => promise.cancel(), + ); + } + + setMembers(memberOids: Set): CancellablePromise { + const promise = api.roles + .set_members({ + database_id: this.database.id, + role_oid: this.oid, + members: [...memberOids], + }) + .run(); + + return new CancellablePromise( + (resolve, reject) => { + promise + .then((rawRole) => { + const newMembers = rawRole.members ?? []; + this._members.reconstruct( + newMembers.map((member) => [member.oid, member]), + ); + return resolve(this); + }, reject) + .catch(reject); + }, + () => promise.cancel(), + ); + } + + delete(): CancellablePromise { + return api.roles + .delete({ database_id: this.database.id, role_oid: this.oid }) + .run(); + } +} diff --git a/mathesar_ui/src/models/Server.ts b/mathesar_ui/src/models/Server.ts new file mode 100644 index 0000000000..1d51f3ed00 --- /dev/null +++ b/mathesar_ui/src/models/Server.ts @@ -0,0 +1,19 @@ +import type { RawServer } from '@mathesar/api/rpc/servers'; + +export class Server { + readonly id: number; + + readonly host: string; + + readonly port: number; + + constructor(props: { rawServer: RawServer }) { + this.id = props.rawServer.id; + this.host = props.rawServer.host; + this.port = props.rawServer.port; + } + + getConnectionString() { + return `${this.host}:${this.port}`; + } +} diff --git a/mathesar_ui/src/packages/json-rpc-client-builder/requests.ts b/mathesar_ui/src/packages/json-rpc-client-builder/requests.ts index 0ad5cfd337..c25af1f163 100644 --- a/mathesar_ui/src/packages/json-rpc-client-builder/requests.ts +++ b/mathesar_ui/src/packages/json-rpc-client-builder/requests.ts @@ -26,6 +26,15 @@ function cancellableFetch( ); } +function getRpcRequestBody(request: RpcRequest, id = 0) { + return { + jsonrpc, + id, + method: request.method, + params: request.params, + }; +} + function makeRpcResponse(value: unknown): RpcResponse { if (hasProperty(value, 'result')) { const response: RpcResult = { @@ -44,12 +53,7 @@ function send(request: RpcRequest): CancellablePromise> { ...request.getHeaders(), 'Content-Type': 'application/json', }, - body: JSON.stringify({ - jsonrpc, - id: 0, - method: request.method, - params: request.params, - }), + body: JSON.stringify(getRpcRequestBody(request)), }); return new CancellablePromise( (resolve) => @@ -67,6 +71,47 @@ function send(request: RpcRequest): CancellablePromise> { ); } +function makeRpcBatchResponse[]>( + values: unknown, +): RpcBatchResponse { + if (!Array.isArray(values)) { + throw new Error('Response is not an array'); + } + return values.map((value) => makeRpcResponse(value)) as RpcBatchResponse; +} + +function sendBatchRequest[]>( + endpoint: string, + headers: Record, + requests: T, +): CancellablePromise> { + const fetch = cancellableFetch(endpoint, { + method: 'POST', + headers: { + ...headers, + 'Content-Type': 'application/json', + }, + body: JSON.stringify( + requests.map((request, index) => getRpcRequestBody(request, index)), + ), + }); + return new CancellablePromise( + (resolve) => + void fetch + .then( + (response) => response.json(), + (rejectionReason) => + resolve( + requests.map(() => + RpcError.fromAnything(rejectionReason), + ) as RpcBatchResponse, + ), + ) + .then((json) => resolve(makeRpcBatchResponse(json))), + () => fetch.cancel(), + ); +} + export type GetHeaders = () => Record; export class RpcRequest { @@ -126,16 +171,23 @@ export class RpcRequest { } } -export type RpcBatchResponse[]> = - CancellablePromise<{ - [K in keyof T]: T[K] extends RpcRequest ? RpcResponse : never; - }>; +export type RpcBatchResponse[]> = { + [K in keyof T]: T[K] extends RpcRequest ? RpcResponse : never; +}; export function batchSend[]>( - ...requests: T -): RpcBatchResponse { - // TODO implement batch sending - throw new Error('Not implemented'); + requests: T, +): CancellablePromise> { + if (requests.length === 0) { + throw new Error('There must be atleast one request'); + } + const [firstRequest, ...rest] = requests; + const { endpoint } = firstRequest; + if (rest.some((request) => request.endpoint !== endpoint)) { + throw new Error('Only RPC requests to the same endpoint can be batched'); + } + // TODO: Decide if headers need to be merged + return sendBatchRequest(endpoint, firstRequest.getHeaders(), requests); } /** diff --git a/mathesar_ui/src/pages/admin-users/AdminNavigation.svelte b/mathesar_ui/src/pages/admin-users/AdminNavigation.svelte index 4a43fb56e0..6bfdb22d03 100644 --- a/mathesar_ui/src/pages/admin-users/AdminNavigation.svelte +++ b/mathesar_ui/src/pages/admin-users/AdminNavigation.svelte @@ -46,7 +46,7 @@ diff --git a/mathesar_ui/src/pages/database/SchemaConstituentCounts.svelte b/mathesar_ui/src/pages/database/schemas/SchemaConstituentCounts.svelte similarity index 100% rename from mathesar_ui/src/pages/database/SchemaConstituentCounts.svelte rename to mathesar_ui/src/pages/database/schemas/SchemaConstituentCounts.svelte diff --git a/mathesar_ui/src/pages/database/SchemaListSkeleton.svelte b/mathesar_ui/src/pages/database/schemas/SchemaListSkeleton.svelte similarity index 100% rename from mathesar_ui/src/pages/database/SchemaListSkeleton.svelte rename to mathesar_ui/src/pages/database/schemas/SchemaListSkeleton.svelte diff --git a/mathesar_ui/src/pages/database/SchemaRow.svelte b/mathesar_ui/src/pages/database/schemas/SchemaRow.svelte similarity index 97% rename from mathesar_ui/src/pages/database/SchemaRow.svelte rename to mathesar_ui/src/pages/database/schemas/SchemaRow.svelte index 32149dc4d3..2e83202e50 100644 --- a/mathesar_ui/src/pages/database/SchemaRow.svelte +++ b/mathesar_ui/src/pages/database/schemas/SchemaRow.svelte @@ -2,7 +2,6 @@ import { createEventDispatcher } from 'svelte'; import { _ } from 'svelte-i18n'; - import type { Database } from '@mathesar/api/rpc/databases'; import type { Schema } from '@mathesar/api/rpc/schemas'; import DropdownMenu from '@mathesar/component-library/dropdown-menu/DropdownMenu.svelte'; import MenuDivider from '@mathesar/component-library/menu/MenuDivider.svelte'; @@ -14,6 +13,7 @@ iconMoreActions, iconNotEditable, } from '@mathesar/icons'; + import type { Database } from '@mathesar/models/Database'; import { getSchemaPageUrl } from '@mathesar/routes/urls'; import { ButtonMenuItem, Icon } from '@mathesar-component-library'; @@ -50,7 +50,7 @@ triggerAppearance="plain" preferredPlacement="bottom-end" icon={iconMoreActions} - menuStyle="--spacing-y:0.8em;" + menuStyle="--Menu__padding-x:0.8em;" > dispatch('edit')} icon={iconEdit}> {$_('edit_schema')} diff --git a/mathesar_ui/src/pages/database/DatabaseDetails.svelte b/mathesar_ui/src/pages/database/schemas/SchemasSection.svelte similarity index 63% rename from mathesar_ui/src/pages/database/DatabaseDetails.svelte rename to mathesar_ui/src/pages/database/schemas/SchemasSection.svelte index 8008fa11a0..7250f40d08 100644 --- a/mathesar_ui/src/pages/database/DatabaseDetails.svelte +++ b/mathesar_ui/src/pages/database/schemas/SchemasSection.svelte @@ -1,21 +1,12 @@ - - -
- - -
- {$_('sync_external_changes')} - -

- {$_('sync_external_changes_structure_help')} -

-

- {$_('sync_external_changes_data_help')} -

-
-
-
- {#if userProfile?.isSuperUser} - editConnectionModal.open()} - > - {$_('edit_connection')} - - deleteConnectionModal.open()} - > - {$_('delete_connection')} - - {/if} -
-
-
-
-

{$_('schemas')} ({schemasMap.size})

diff --git a/mathesar_ui/src/pages/database/settings/SettingsContentLayout.svelte b/mathesar_ui/src/pages/database/settings/SettingsContentLayout.svelte new file mode 100644 index 0000000000..a9bc582a92 --- /dev/null +++ b/mathesar_ui/src/pages/database/settings/SettingsContentLayout.svelte @@ -0,0 +1,34 @@ +
+
+
+ +
+
+ +
+
+
+ +
+
+ + diff --git a/mathesar_ui/src/pages/database/settings/SettingsWrapper.svelte b/mathesar_ui/src/pages/database/settings/SettingsWrapper.svelte new file mode 100644 index 0000000000..1831c267a2 --- /dev/null +++ b/mathesar_ui/src/pages/database/settings/SettingsWrapper.svelte @@ -0,0 +1,81 @@ + + + + +
+ +
+
+ + diff --git a/mathesar_ui/src/pages/database/settings/collaborators/AddCollaboratorModal.svelte b/mathesar_ui/src/pages/database/settings/collaborators/AddCollaboratorModal.svelte new file mode 100644 index 0000000000..79b4825a90 --- /dev/null +++ b/mathesar_ui/src/pages/database/settings/collaborators/AddCollaboratorModal.svelte @@ -0,0 +1,94 @@ + + + form.reset()}> + + {$_('add_collaborator')} + +
+ user.id), + getLabel: (option) => { + if (option) { + return usersMap.get(option)?.username ?? String(option); + } + return $_('select_user'); + }, + autoSelect: 'none', + }, + }} + /> + +
+ +
diff --git a/mathesar_ui/src/pages/database/settings/collaborators/CollaboratorRow.svelte b/mathesar_ui/src/pages/database/settings/collaborators/CollaboratorRow.svelte new file mode 100644 index 0000000000..7a1c494d23 --- /dev/null +++ b/mathesar_ui/src/pages/database/settings/collaborators/CollaboratorRow.svelte @@ -0,0 +1,78 @@ + + + +
+ {#if user} +
{userName}
+
{user.email}
+ {:else} + {collaborator.user_id} + {/if} +
+
+ +
+
+ {#if configuredRole} + {configuredRole.name} + {:else} + {$configuredRoleId} + {/if} +
+
+ +
+
+
+ + + confirmDelete({ + identifierName: userName, + identifierType: $_('collaborator'), + })} + onClick={() => $databaseContext.deleteCollaborator(collaborator)} + icon={{ ...iconDeleteMajor, size: '0.8em' }} + label="" + appearance="secondary" + /> + + + diff --git a/mathesar_ui/src/pages/database/settings/collaborators/Collaborators.svelte b/mathesar_ui/src/pages/database/settings/collaborators/Collaborators.svelte new file mode 100644 index 0000000000..f74c581d0e --- /dev/null +++ b/mathesar_ui/src/pages/database/settings/collaborators/Collaborators.svelte @@ -0,0 +1,106 @@ + + + + + {$_('collaborators')} + + + {#if isSuccess} + + {/if} + + {#if isLoading} + + {:else if isSuccess} +
+ + {$_('mathesar_user')} + {$_('role')} + {$_('actions')} + {#each [...($collaborators.resolvedValue?.values() ?? [])] as collaborator (collaborator.id)} + + {/each} + +
+ {:else} + + {/if} +
+ +{#if $users.resolvedValue && $configuredRoles.resolvedValue && $collaborators.resolvedValue} + +{/if} + +{#if $configuredRoles.resolvedValue && $users.resolvedValue && targetCollaborator} + +{/if} + + diff --git a/mathesar_ui/src/pages/database/settings/collaborators/EditRoleForCollaboratorModal.svelte b/mathesar_ui/src/pages/database/settings/collaborators/EditRoleForCollaboratorModal.svelte new file mode 100644 index 0000000000..8fe7fe0c8f --- /dev/null +++ b/mathesar_ui/src/pages/database/settings/collaborators/EditRoleForCollaboratorModal.svelte @@ -0,0 +1,65 @@ + + + form.reset()}> + + {$_('edit_role_for_collaborator_value', { + values: { + collaborator: userName, + }, + })} + +
+ +
+ +
diff --git a/mathesar_ui/src/pages/database/settings/collaborators/SelectConfiguredRoleField.svelte b/mathesar_ui/src/pages/database/settings/collaborators/SelectConfiguredRoleField.svelte new file mode 100644 index 0000000000..b508350f47 --- /dev/null +++ b/mathesar_ui/src/pages/database/settings/collaborators/SelectConfiguredRoleField.svelte @@ -0,0 +1,33 @@ + + + r.id), + getLabel: (option) => { + if (option) { + return configuredRolesMap.get(option)?.name ?? String(option); + } + return $_('select_role'); + }, + autoSelect: 'none', + }, + }} + help={$_('collaborator_role_help')} +/> diff --git a/mathesar_ui/src/pages/database/settings/databaseSettingsUtils.ts b/mathesar_ui/src/pages/database/settings/databaseSettingsUtils.ts new file mode 100644 index 0000000000..8edce0b131 --- /dev/null +++ b/mathesar_ui/src/pages/database/settings/databaseSettingsUtils.ts @@ -0,0 +1,183 @@ +import { getContext, setContext } from 'svelte'; +import { type Readable, type Writable, derived, writable } from 'svelte/store'; + +import userApi, { type User } from '@mathesar/api/rest/users'; +import type { Collaborator } from '@mathesar/models/Collaborator'; +import type { ConfiguredRole } from '@mathesar/models/ConfiguredRole'; +import type { Database } from '@mathesar/models/Database'; +import type { Role } from '@mathesar/models/Role'; +import AsyncStore from '@mathesar/stores/AsyncStore'; +import { CancellablePromise, ImmutableMap } from '@mathesar-component-library'; + +const contextKey = Symbol('database settings store'); + +export type CombinedLoginRole = { + name: string; + role?: Role; + configuredRole?: ConfiguredRole; +}; + +// TODO: Make CancellablePromise chainable +const getUsersPromise = () => { + const promise = userApi.list(); + return new CancellablePromise>( + (resolve, reject) => { + promise + .then( + (response) => + resolve( + new ImmutableMap(response.results.map((user) => [user.id, user])), + ), + (err) => reject(err), + ) + .catch((err) => reject(err)); + }, + () => promise.cancel(), + ); +}; + +class DatabaseSettingsContext { + database: Database; + + configuredRoles; + + roles; + + combinedLoginRoles: Readable; + + collaborators; + + users: AsyncStore>; + + constructor(database: Database) { + this.database = database; + this.configuredRoles = database.constructConfiguredRolesStore(); + this.roles = database.constructRolesStore(); + this.combinedLoginRoles = derived( + [this.roles, this.configuredRoles], + ([$roles, $configuredRoles]) => { + const isLoading = $configuredRoles.isLoading || $roles.isLoading; + if (isLoading) { + return []; + } + const isStable = $configuredRoles.isStable && $roles.isStable; + const loginRoles = $roles.resolvedValue?.filterValues( + (value) => value.login, + ); + const configuredRoles = $configuredRoles.resolvedValue?.mapKeys( + (cr) => cr.name, + ); + if (isStable && loginRoles && configuredRoles) { + return [...loginRoles.values()].map((role) => ({ + name: role.name, + role, + configuredRole: configuredRoles.get(role.name), + })); + } + if ($configuredRoles.isStable && configuredRoles) { + return [...configuredRoles.values()].map((configuredRole) => ({ + name: configuredRole.name, + configuredRole, + })); + } + return []; + }, + ); + this.collaborators = database.constructCollaboratorsStore(); + this.users = new AsyncStore(getUsersPromise); + } + + async configureRole(combinedLoginRole: CombinedLoginRole, password: string) { + if (combinedLoginRole.configuredRole) { + return combinedLoginRole.configuredRole.setPassword(password); + } + + if (combinedLoginRole.role) { + const configuredRole = await combinedLoginRole.role.configure(password); + this.configuredRoles.updateResolvedValue((configuredRoles) => + configuredRoles.with(configuredRole.id, configuredRole), + ); + } + + return undefined; + } + + async removeConfiguredRole(configuredRole: ConfiguredRole) { + await configuredRole.delete(); + this.configuredRoles.updateResolvedValue((configuredRoles) => + configuredRoles.without(configuredRole.id), + ); + /** + * When a configured role is removed from the Role Configuration page, + * Collaborators list needs to be reset, since the drop statement cascades. + * + * TODO_BETA: Discuss on whether we should cascade or throw error? + */ + this.collaborators.reset(); + } + + async addCollaborator( + userId: User['id'], + configuredRoleId: ConfiguredRole['id'], + ) { + const newCollaborator = await this.database.addCollaborator( + userId, + configuredRoleId, + ); + this.collaborators.updateResolvedValue((collaborators) => + collaborators.with(newCollaborator.id, newCollaborator), + ); + } + + async deleteCollaborator(collaborator: Collaborator) { + await collaborator.delete(); + this.collaborators.updateResolvedValue((c) => c.without(collaborator.id)); + } + + async addRole( + props: + | { + roleName: Role['name']; + login: false; + password?: never; + } + | { roleName: Role['name']; login: true; password: string }, + ) { + const newRole = await this.database.addRole( + props.roleName, + props.login, + props.password, + ); + this.roles.updateResolvedValue((r) => r.with(newRole.oid, newRole)); + } + + async deleteRole(role: Role) { + await role.delete(); + this.roles.updateResolvedValue((r) => r.without(role.oid)); + // When a role is deleted, both Collaborators & ConfiguredRoles needs to be reset + this.configuredRoles.reset(); + this.collaborators.reset(); + } +} + +export function getDatabaseSettingsContext(): Readable { + const store = getContext>(contextKey); + if (store === undefined) { + throw Error('Database settings context has not been set'); + } + return store; +} + +export function setDatabaseSettingsContext( + database: Database, +): Readable { + let store = getContext>(contextKey); + const databaseSettingsContext = new DatabaseSettingsContext(database); + if (store !== undefined) { + store.set(databaseSettingsContext); + return store; + } + store = writable(databaseSettingsContext); + setContext(contextKey, store); + return store; +} diff --git a/mathesar_ui/src/pages/database/settings/role-configuration/ConfigureRoleModal.svelte b/mathesar_ui/src/pages/database/settings/role-configuration/ConfigureRoleModal.svelte new file mode 100644 index 0000000000..c7f2eab7a5 --- /dev/null +++ b/mathesar_ui/src/pages/database/settings/role-configuration/ConfigureRoleModal.svelte @@ -0,0 +1,83 @@ + + + form.reset()}> + + {$_('configure_value', { + values: { value: combinedLoginRole.name }, + })} + +
+ + + + {$_('role_configured_all_databases_in_server', { + values: { + server: database.server.getConnectionString(), + }, + })} + + +
+ +
diff --git a/mathesar_ui/src/pages/database/settings/role-configuration/RoleConfiguration.svelte b/mathesar_ui/src/pages/database/settings/role-configuration/RoleConfiguration.svelte new file mode 100644 index 0000000000..1aca99fedc --- /dev/null +++ b/mathesar_ui/src/pages/database/settings/role-configuration/RoleConfiguration.svelte @@ -0,0 +1,169 @@ + + + + + {$_('role_configuration')} + + {#if isLoading} + + {:else} + {#if $combinedLoginRoles.length > 0} +
+ + {$_('role')} + {$_('actions')} + + {#each $combinedLoginRoles as combinedLoginRole (combinedLoginRole.name)} + + + {combinedLoginRole.name} + + + + {#if combinedLoginRole.configuredRole} +
+ + + confirm({ + title: { + component: PhraseContainingIdentifier, + props: { + identifier: combinedLoginRole.name, + wrappingString: $_( + 'remove_configuration_for_identifier', + ), + }, + }, + body: $_('removing_role_configuration_warning', { + values: { + server: + combinedLoginRole.configuredRole?.database.server.getConnectionString(), + }, + }), + proceedButton: { + label: $_('remove_configuration'), + icon: iconDeleteMajor, + }, + })} + label={$_('remove')} + onClick={() => removeConfiguredRole(combinedLoginRole)} + /> +
+ {:else if combinedLoginRole.role} + + {/if} +
+ {/each} +
+
+ {/if} + + {#if $configuredRoles.error} + + {$configuredRoles.error} + + {/if} + {#if $roles.error} + + {$roles.error} + + {/if} + {/if} +
+ +{#if targetCombinedLoginRole} + +{/if} + + diff --git a/mathesar_ui/src/pages/database/settings/roles/CreateRoleModal.svelte b/mathesar_ui/src/pages/database/settings/roles/CreateRoleModal.svelte new file mode 100644 index 0000000000..ccc193b4f1 --- /dev/null +++ b/mathesar_ui/src/pages/database/settings/roles/CreateRoleModal.svelte @@ -0,0 +1,115 @@ + + + form.reset()}> + + {$_('create_role')} + +
+ + + + + + + + {#if login} + + + {/if} +
+ + +
diff --git a/mathesar_ui/src/pages/database/settings/roles/ModifyRoleMembers.svelte b/mathesar_ui/src/pages/database/settings/roles/ModifyRoleMembers.svelte new file mode 100644 index 0000000000..cdfd9db7df --- /dev/null +++ b/mathesar_ui/src/pages/database/settings/roles/ModifyRoleMembers.svelte @@ -0,0 +1,154 @@ + + + form.reset()}> + + {$_('edit_child_roles_for_parent', { + values: { + parent: parentRole.name, + }, + })} + +
+ +
+ {#if $memberOids.size > 0} + {#each [...$memberOids] as memberOid (memberOid)} +
+ + {rolesMap.get(memberOid)?.name ?? memberOid} + + + + +
+ {/each} + {:else} + + {$_('role_has_no_child_roles')} + + {/if} +
+
+ +
+ + diff --git a/mathesar_ui/src/pages/database/settings/roles/RoleRow.svelte b/mathesar_ui/src/pages/database/settings/roles/RoleRow.svelte new file mode 100644 index 0000000000..88ead522d6 --- /dev/null +++ b/mathesar_ui/src/pages/database/settings/roles/RoleRow.svelte @@ -0,0 +1,105 @@ + + +{role.name} + + {#if role.login} + {$_('yes')} + {/if} + + +
+
+ {#each [...$members.values()] as member (member.oid)} + + {rolesMap.get(member.oid)?.name ?? ''} + + {/each} +
+
+ +
+
+
+ + + confirm({ + title: { + component: PhraseContainingIdentifier, + props: { + identifier: role.name, + wrappingString: $_('drop_role_with_identifier'), + }, + }, + body: [ + $_('action_cannot_be_undone'), + $_('drop_role_warning'), + $_('are_you_sure_to_proceed'), + ], + proceedButton: { + label: $_('drop_role'), + icon: iconDeleteMajor, + }, + })} + label={$_('drop_role')} + onClick={dropRole} + /> + + + diff --git a/mathesar_ui/src/pages/database/settings/roles/Roles.svelte b/mathesar_ui/src/pages/database/settings/roles/Roles.svelte new file mode 100644 index 0000000000..6739b68f8b --- /dev/null +++ b/mathesar_ui/src/pages/database/settings/roles/Roles.svelte @@ -0,0 +1,94 @@ + + + + + {$_('roles')} + + + {#if !$roles.isLoading} + + {/if} + + {#if $roles.isLoading} + + {:else if $roles.isOk && $roles.resolvedValue} +
+ + {$_('role')} + + LOGIN + + {$_('child_roles')} + + {$_('actions')} + {#each roleList as role (role.oid)} + + {/each} + +
+ {:else if $roles.error} + + {$roles.error} + + {/if} +
+ + + +{#if $roles.resolvedValue && targetRole} + +{/if} + + diff --git a/mathesar_ui/src/pages/exploration/ExplorationPage.svelte b/mathesar_ui/src/pages/exploration/ExplorationPage.svelte index 9af3683cf6..fd103a1cee 100644 --- a/mathesar_ui/src/pages/exploration/ExplorationPage.svelte +++ b/mathesar_ui/src/pages/exploration/ExplorationPage.svelte @@ -3,9 +3,9 @@ import { router } from 'tinro'; import type { QueryInstance } from '@mathesar/api/rest/types/queries'; - import type { Database } from '@mathesar/api/rpc/databases'; import type { Schema } from '@mathesar/api/rpc/schemas'; import LayoutWithHeader from '@mathesar/layouts/LayoutWithHeader.svelte'; + import type { Database } from '@mathesar/models/Database'; import { getSchemaPageUrl } from '@mathesar/routes/urls'; import { currentDbAbstractTypes } from '@mathesar/stores/abstract-types'; import type { AbstractTypesMap } from '@mathesar/stores/abstract-types/types'; diff --git a/mathesar_ui/src/pages/exploration/Header.svelte b/mathesar_ui/src/pages/exploration/Header.svelte index c50e7156a8..98ada6f753 100644 --- a/mathesar_ui/src/pages/exploration/Header.svelte +++ b/mathesar_ui/src/pages/exploration/Header.svelte @@ -2,10 +2,10 @@ import { _ } from 'svelte-i18n'; import type { QueryInstance } from '@mathesar/api/rest/types/queries'; - import type { Database } from '@mathesar/api/rpc/databases'; import type { Schema } from '@mathesar/api/rpc/schemas'; import EntityPageHeader from '@mathesar/components/EntityPageHeader.svelte'; import { iconExploration, iconInspector } from '@mathesar/icons'; + import type { Database } from '@mathesar/models/Database'; import { getExplorationEditorPageUrl } from '@mathesar/routes/urls'; import { Button, Icon } from '@mathesar-component-library'; diff --git a/mathesar_ui/src/pages/home/DatabaseRow.svelte b/mathesar_ui/src/pages/home/DatabaseRow.svelte index e807f733fe..210e6e081d 100644 --- a/mathesar_ui/src/pages/home/DatabaseRow.svelte +++ b/mathesar_ui/src/pages/home/DatabaseRow.svelte @@ -1,7 +1,7 @@