Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 41 additions & 6 deletions mathesar_ui/src/systems/table-view/context-menu/contextMenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
type ClientPosition,
type ContextMenuController,
type ModalController,
buttonMenuEntry,
menuSection,
subMenu,
} from '@mathesar/component-library';
Expand All @@ -18,10 +19,10 @@ import type RecordStore from '@mathesar/systems/record-view/RecordStore';
import { takeFirstAndOnly } from '@mathesar/utils/iterUtils';
import { match } from '@mathesar/utils/patternMatching';

import { getRowActionsData } from '../row-actions/RowActionsDataProvider';

import { copyCells } from './entries/copyCells';
import { deleteColumn } from './entries/deleteColumn';
import { deleteRecords } from './entries/deleteRecords';
import { duplicateRecord } from './entries/duplicateRecord';
import { modifyFilters } from './entries/modifyFilters';
import { modifyGrouping } from './entries/modifyGrouping';
import { modifySorting } from './entries/modifySorting';
Expand Down Expand Up @@ -55,15 +56,50 @@ export function openTableCellContextMenu({
const { selection } = tabularData;

function* getEntriesForMultipleRows(rowIds: string[]) {
yield* deleteRecords({ tabularData, rowIds });
const rowActionsData = getRowActionsData({
rowIds,
tabularData,
modalRecordView,
});

for (const action of rowActionsData.actions) {
if (action.id === 'delete-records') {
yield buttonMenuEntry({
icon: action.icon,
label: action.label,
danger: action.danger,
onClick: action.onClick,
});
}
}
}

function* getEntriesForOneRow(rowId: string) {
const recordId = tabularData.getRecordIdFromRowId(rowId);
yield* viewRowRecord({ tabularData, recordId, modalRecordView });
yield* duplicateRecord({ tabularData, rowId });

yield* getEntriesForMultipleRows([rowId]);
const rowActionsData = getRowActionsData({
rowIds: [rowId],
tabularData,
modalRecordView,
});

for (const action of rowActionsData.actions) {
if (action.id === 'duplicate-record') {
yield buttonMenuEntry({
icon: action.icon,
label: action.label,
onClick: action.onClick,
});
} else if (action.id === 'delete-records') {
yield buttonMenuEntry({
icon: action.icon,
label: action.label,
danger: action.danger,
onClick: action.onClick,
});
}
}
}

function* getEntriesForArbitraryRows(rowIds: Iterable<string>) {
Expand Down Expand Up @@ -109,7 +145,6 @@ export function openTableCellContextMenu({
}

function* getEntriesForMultipleCells(cellIds: string[]) {
// Check if any of the selected cells are joined columns
const allColumns = get(tabularData.allColumns);
const hasJoinedColumn = cellIds.some((cellId) => {
const { columnId } = parseCellId(cellId);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { get } from 'svelte/store';
import { _ } from 'svelte-i18n';

import {
iconDeleteMajor,
iconDuplicateRecord,
iconLinkToRecordPage,
iconModalRecordView,
} from '@mathesar/icons';
import { confirm } from '@mathesar/stores/confirmation';
import { storeToGetRecordPageUrl } from '@mathesar/stores/storeBasedUrls';
import type { TabularData } from '@mathesar/stores/table-data';
import { currentTablesMap } from '@mathesar/stores/tables';
import { toast } from '@mathesar/stores/toast';
import RecordStore from '@mathesar/systems/record-view/RecordStore';
import type { ModalController } from '@mathesar-component-library';
import type { IconProps } from '@mathesar-component-library/types';

export type RowIdentifier = string | number;

export interface RowAction {
id: string;
label: string;
onClick: () => void;
icon?: IconProps;
href?: string;
danger?: boolean;
disabled?: boolean;
}

export interface RowActionsData {
actions: RowAction[];
}

export function getRowActionsData(params: {
rowIds: RowIdentifier[];
tabularData: TabularData;
modalRecordView?: ModalController<RecordStore>;
}): RowActionsData {
const { rowIds, tabularData, modalRecordView } = params;
const actions: RowAction[] = [];

const canInsertRecords = get(tabularData.canInsertRecords);
const canDeleteRecords = get(tabularData.canDeleteRecords);
const canViewLinkedEntities = get(tabularData.canViewLinkedEntities);

const rows = get(tabularData.recordsData.selectableRowsMap);

const firstRowId = rowIds[0];
const firstRow =
firstRowId !== undefined ? rows.get(String(firstRowId)) : undefined;

let recordId: string | number | undefined;
if (firstRow) {
try {
const result = tabularData.getRecordIdFromRowId(String(firstRowId));
recordId =
typeof result === 'string' || typeof result === 'number'
? result
: undefined;
} catch {
recordId = undefined;
}
}

if (
canViewLinkedEntities &&
rowIds.length === 1 &&
recordId !== undefined &&
modalRecordView
) {
actions.push({
id: 'quick-view-record',
label: get(_)('quick_view_record'),
icon: iconModalRecordView,
onClick: () => {
const containingTable = get(currentTablesMap).get(
tabularData.table.oid,
);
if (!containingTable) return;

const recordStore = new RecordStore({
table: containingTable,
recordPk: String(recordId),
});

modalRecordView.open(recordStore);
},
});
}

if (canViewLinkedEntities && rowIds.length === 1 && recordId !== undefined) {
const getRecordPageUrl = get(storeToGetRecordPageUrl);
const recordPageUrl = getRecordPageUrl({
tableId: tabularData.table.oid,
recordId,
});

if (recordPageUrl) {
actions.push({
id: 'open-record',
label: get(_)('open_record'),
icon: iconLinkToRecordPage,
href: recordPageUrl,
onClick: () => {},
});
}
}

if (canInsertRecords && rowIds.length === 1 && firstRow) {
actions.push({
id: 'duplicate-record',
label: get(_)('duplicate_record'),
icon: iconDuplicateRecord,
onClick: () => {
void tabularData.recordsData.duplicateRecord(firstRow);
},
});
}

if (canDeleteRecords && rowIds.length > 0) {
actions.push({
id: 'delete-records',
label: get(_)('delete_records', {
values: { count: rowIds.length },
}),
icon: iconDeleteMajor,
danger: true,
onClick: () => {
void confirm({
title: get(_)('delete_records_question', {
values: { count: rowIds.length },
}),
body: [
get(_)('deleted_records_cannot_be_recovered', {
values: { count: rowIds.length },
}),
get(_)('are_you_sure_to_proceed'),
],
onProceed: () =>
tabularData.recordsData.deleteSelected(rowIds.map(String)),
onError: (e) => toast.fromError(e),
onSuccess: (count) => {
toast.success({
title: get(_)('count_records_deleted_successfully', {
values: { count },
}),
});
},
});
},
});
}

return { actions };
}
Original file line number Diff line number Diff line change
@@ -1,108 +1,65 @@
<script lang="ts">
import { _ } from 'svelte-i18n';

import {
iconDeleteMajor,
iconDuplicateRecord,
iconLinkToRecordPage,
iconModalRecordView,
} from '@mathesar/icons';
import { confirmDelete } from '@mathesar/stores/confirmation';
import { storeToGetRecordPageUrl } from '@mathesar/stores/storeBasedUrls';
import {
extractPrimaryKeyValue,
getTabularDataStoreFromContext,
} from '@mathesar/stores/table-data';
import { currentTablesMap } from '@mathesar/stores/tables';
import { toast } from '@mathesar/stores/toast';
import RecordStore from '@mathesar/systems/record-view/RecordStore';
import { getTabularDataStoreFromContext } from '@mathesar/stores/table-data';
import { modalRecordViewContext } from '@mathesar/systems/record-view-modal/modalRecordViewContext';
import { takeFirstAndOnly } from '@mathesar/utils/iterUtils';
import { AnchorButton, Button, Icon } from '@mathesar-component-library';

import {
type RowAction,
getRowActionsData,
} from '../../row-actions/RowActionsDataProvider';

const tabularData = getTabularDataStoreFromContext();
const modalRecordView = modalRecordViewContext.get();

$: ({ table, selection, recordsData, columnsDataStore, canDeleteRecords } =
$tabularData);
$: selectedRowIds = $selection.rowIds;
$: selectedRowCount = selectedRowIds.size;
$: ({ columns } = columnsDataStore);
$: ({ selectableRowsMap } = recordsData);
$: recordId = (() => {
const id = takeFirstAndOnly(selectedRowIds);
if (!id) return undefined;
const row = $selectableRowsMap.get(id);
if (!row) return undefined;
try {
return extractPrimaryKeyValue(row.record, $columns);
} catch (e) {
return undefined;
}
})();
$: recordPageLink = $storeToGetRecordPageUrl({
tableId: table.oid,
recordId,
$: ({ selection } = $tabularData);
$: selectedRowIds = Array.from($selection.rowIds);
$: rowActionsData = getRowActionsData({
rowIds: selectedRowIds,
tabularData: $tabularData,
modalRecordView,
});

function quickViewRecord() {
if (!modalRecordView) return;
if (recordId === undefined) return;
const containingTable = $currentTablesMap.get(table.oid);
if (!containingTable) return;
const recordStore = new RecordStore({
table: containingTable,
recordPk: String(recordId),
});
modalRecordView.open(recordStore);
}

async function handleDeleteRecords() {
void confirmDelete({
identifierType: $_('multiple_records', {
values: { count: selectedRowCount },
}),
body: [
$_('deleted_records_cannot_be_recovered', {
values: { count: selectedRowCount },
}),
$_('are_you_sure_to_proceed'),
],
onProceed: () => recordsData.deleteSelected(selectedRowIds),
onError: (e) => toast.fromError(e),
onSuccess: (count) => {
toast.success({
title: $_('count_records_deleted_successfully', {
values: { count },
}),
});
},
});
function getActionIcon(action: RowAction) {
switch (action.id) {
case 'quick-view-record':
return iconModalRecordView;
case 'open-record':
return iconLinkToRecordPage;
case 'duplicate-record':
return iconDuplicateRecord;
case 'delete-records':
return iconDeleteMajor;
default:
return iconModalRecordView;
}
}
</script>

<div class="actions-container">
{#if recordPageLink}
<Button on:click={quickViewRecord} appearance="action">
<Icon {...iconModalRecordView} />
<span>{$_('quick_view_record')}</span>
</Button>

<AnchorButton href={recordPageLink} appearance="action">
<Icon {...iconLinkToRecordPage} />
<span>{$_('open_record')}</span>
</AnchorButton>
{/if}

<Button
on:click={handleDeleteRecords}
disabled={!$canDeleteRecords}
appearance="danger"
>
<Icon {...iconDeleteMajor} />
<span>
{$_('delete_records', { values: { count: selectedRowCount } })}
</span>
</Button>
{#each rowActionsData.actions as action}
{@const actionIcon = getActionIcon(action)}
{#if action.href}
<AnchorButton href={action.href} appearance="action">
<Icon {...actionIcon} />
<span>{action.label}</span>
</AnchorButton>
{:else if actionIcon}
<Button
on:click={() => action.onClick?.()}
disabled={action.disabled}
appearance={action.danger ? 'danger' : 'action'}
>
<Icon {...actionIcon} />
<span>{action.label}</span>
</Button>
{/if}
{/each}
</div>

<style lang="scss">
Expand Down
Loading