WIP - sharing list views
This commit is contained in:
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* Inserts a system object_definition row for SavedListView.
|
||||||
|
* This allows saved_list_views records to be shared via record_shares
|
||||||
|
* (which requires a valid objectDefinitionId FK).
|
||||||
|
*
|
||||||
|
* @param { import("knex").Knex } knex
|
||||||
|
* @returns { Promise<void> }
|
||||||
|
*/
|
||||||
|
exports.up = async function (knex) {
|
||||||
|
// Only insert if it doesn't already exist (idempotent)
|
||||||
|
const existing = await knex('object_definitions')
|
||||||
|
.where({ apiName: 'SavedListView' })
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
await knex('object_definitions').insert({
|
||||||
|
apiName: 'SavedListView',
|
||||||
|
label: 'Saved List View',
|
||||||
|
pluralLabel: 'Saved List Views',
|
||||||
|
description: 'System object for sharing saved list views via record_shares',
|
||||||
|
isSystem: true,
|
||||||
|
isCustom: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param { import("knex").Knex } knex
|
||||||
|
* @returns { Promise<void> }
|
||||||
|
*/
|
||||||
|
exports.down = async function (knex) {
|
||||||
|
await knex('object_definitions')
|
||||||
|
.where({ apiName: 'SavedListView' })
|
||||||
|
.delete();
|
||||||
|
};
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { IsString, IsNotEmpty, IsArray, IsOptional, IsBoolean } from 'class-validator';
|
import { IsString, IsNotEmpty, IsArray, IsOptional } from 'class-validator';
|
||||||
|
|
||||||
export class CreateSavedViewDto {
|
export class CreateSavedViewDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -33,10 +33,6 @@ export class UpdateSavedViewDto {
|
|||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
name?: string;
|
name?: string;
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsBoolean()
|
|
||||||
isShared?: boolean;
|
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsArray()
|
@IsArray()
|
||||||
filters?: Array<{
|
filters?: Array<{
|
||||||
|
|||||||
@@ -7,12 +7,15 @@ import {
|
|||||||
Body,
|
Body,
|
||||||
Param,
|
Param,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
|
ForbiddenException,
|
||||||
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
import { CurrentUser } from '../auth/current-user.decorator';
|
import { CurrentUser } from '../auth/current-user.decorator';
|
||||||
import { TenantId } from '../tenant/tenant.decorator';
|
import { TenantId } from '../tenant/tenant.decorator';
|
||||||
import { SavedListViewService } from './saved-list-view.service';
|
import { SavedListViewService } from './saved-list-view.service';
|
||||||
import { CreateSavedViewDto, UpdateSavedViewDto } from './dto/saved-list-view.dto';
|
import { CreateSavedViewDto, UpdateSavedViewDto } from './dto/saved-list-view.dto';
|
||||||
|
import { CreateRecordShareDto } from '../rbac/dto/create-record-share.dto';
|
||||||
|
|
||||||
@Controller('saved-views')
|
@Controller('saved-views')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@@ -55,4 +58,35 @@ export class SavedListViewController {
|
|||||||
) {
|
) {
|
||||||
return this.savedListViewService.remove(tenantId, user.userId, id);
|
return this.savedListViewService.remove(tenantId, user.userId, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Sharing endpoints (reuse record_shares table) ────────────────────────
|
||||||
|
|
||||||
|
@Get(':id/shares')
|
||||||
|
getShares(
|
||||||
|
@TenantId() tenantId: string,
|
||||||
|
@CurrentUser() user: any,
|
||||||
|
@Param('id') id: string,
|
||||||
|
) {
|
||||||
|
return this.savedListViewService.getShares(tenantId, user.userId, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/shares')
|
||||||
|
createShare(
|
||||||
|
@TenantId() tenantId: string,
|
||||||
|
@CurrentUser() user: any,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: CreateRecordShareDto,
|
||||||
|
) {
|
||||||
|
return this.savedListViewService.createShare(tenantId, user.userId, id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id/shares/:shareId')
|
||||||
|
removeShare(
|
||||||
|
@TenantId() tenantId: string,
|
||||||
|
@CurrentUser() user: any,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Param('shareId') shareId: string,
|
||||||
|
) {
|
||||||
|
return this.savedListViewService.removeShare(tenantId, user.userId, id, shareId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,61 @@
|
|||||||
import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
|
import { Injectable, NotFoundException, ForbiddenException, BadRequestException } from '@nestjs/common';
|
||||||
import { TenantDatabaseService } from '../tenant/tenant-database.service';
|
import { TenantDatabaseService } from '../tenant/tenant-database.service';
|
||||||
import { CreateSavedViewDto, UpdateSavedViewDto } from './dto/saved-list-view.dto';
|
import { CreateSavedViewDto, UpdateSavedViewDto } from './dto/saved-list-view.dto';
|
||||||
|
import { RecordShare } from '../models/record-share.model';
|
||||||
|
import { ObjectDefinition } from '../models/object-definition.model';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SavedListViewService {
|
export class SavedListViewService {
|
||||||
constructor(private readonly tenantDbService: TenantDatabaseService) {}
|
constructor(private readonly tenantDbService: TenantDatabaseService) {}
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the system object_definition ID for SavedListView.
|
||||||
|
* This is needed to create record_shares rows for saved views.
|
||||||
|
*/
|
||||||
|
private async getSavedViewObjectDefId(knex: any): Promise<string> {
|
||||||
|
const objectDef = await ObjectDefinition.query(knex)
|
||||||
|
.findOne({ apiName: 'SavedListView' });
|
||||||
|
if (!objectDef) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'SavedListView system object not found. Please run migrations.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return objectDef.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CRUD ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns all saved views visible to the user for a given object:
|
* Returns all saved views visible to the user for a given object:
|
||||||
* - Views owned by the user (private or shared)
|
* - Views owned by the user
|
||||||
* - Views owned by other users that are shared with the tenant
|
* - Views shared with the user via record_shares
|
||||||
*/
|
*/
|
||||||
async findByObject(tenantId: string, userId: string, objectApiName: string) {
|
async findByObject(tenantId: string, userId: string, objectApiName: string) {
|
||||||
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
|
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
|
||||||
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
|
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
|
||||||
|
|
||||||
|
const objectDefId = await this.getSavedViewObjectDefId(knex);
|
||||||
|
|
||||||
|
// IDs of views shared with this user via record_shares
|
||||||
|
const sharedViewIds = await RecordShare.query(knex)
|
||||||
|
.where({ objectDefinitionId: objectDefId, granteeUserId: userId })
|
||||||
|
.whereNull('revokedAt')
|
||||||
|
.where(builder => {
|
||||||
|
builder.whereNull('expiresAt').orWhere('expiresAt', '>', new Date());
|
||||||
|
})
|
||||||
|
.select('recordId');
|
||||||
|
|
||||||
|
const sharedIds = sharedViewIds.map((s: any) => s.recordId);
|
||||||
|
|
||||||
const rows = await knex('saved_list_views')
|
const rows = await knex('saved_list_views')
|
||||||
.where({ object_api_name: objectApiName })
|
.where({ object_api_name: objectApiName })
|
||||||
.andWhere(function () {
|
.andWhere(function () {
|
||||||
this.where({ user_id: userId }).orWhere({ is_shared: true });
|
this.where({ user_id: userId });
|
||||||
|
if (sharedIds.length > 0) {
|
||||||
|
this.orWhereIn('id', sharedIds);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.orderBy('created_at', 'asc');
|
.orderBy('created_at', 'asc');
|
||||||
|
|
||||||
@@ -29,8 +66,6 @@ export class SavedListViewService {
|
|||||||
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
|
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
|
||||||
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
|
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
|
||||||
|
|
||||||
// MySQL Knex returns [0] for UUID PKs (not auto-increment), so generate the
|
|
||||||
// UUID in application code and use it directly for the subsequent fetch.
|
|
||||||
const id = require('crypto').randomUUID();
|
const id = require('crypto').randomUUID();
|
||||||
|
|
||||||
await knex('saved_list_views').insert({
|
await knex('saved_list_views').insert({
|
||||||
@@ -61,7 +96,6 @@ export class SavedListViewService {
|
|||||||
|
|
||||||
const updates: Record<string, any> = { updated_at: knex.fn.now() };
|
const updates: Record<string, any> = { updated_at: knex.fn.now() };
|
||||||
if (dto.name !== undefined) updates.name = dto.name;
|
if (dto.name !== undefined) updates.name = dto.name;
|
||||||
if (dto.isShared !== undefined) updates.is_shared = dto.isShared;
|
|
||||||
if (dto.filters !== undefined) updates.filters = JSON.stringify(dto.filters);
|
if (dto.filters !== undefined) updates.filters = JSON.stringify(dto.filters);
|
||||||
if (dto.sort !== undefined) updates.sort = dto.sort ? JSON.stringify(dto.sort) : null;
|
if (dto.sort !== undefined) updates.sort = dto.sort ? JSON.stringify(dto.sort) : null;
|
||||||
if (dto.description !== undefined) updates.description = dto.description;
|
if (dto.description !== undefined) updates.description = dto.description;
|
||||||
@@ -82,10 +116,133 @@ export class SavedListViewService {
|
|||||||
throw new ForbiddenException('You can only delete views you own');
|
throw new ForbiddenException('You can only delete views you own');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Also clean up any record_shares for this view
|
||||||
|
const objectDefId = await this.getSavedViewObjectDefId(knex);
|
||||||
|
await RecordShare.query(knex)
|
||||||
|
.where({ objectDefinitionId: objectDefId, recordId: id })
|
||||||
|
.delete();
|
||||||
|
|
||||||
await knex('saved_list_views').where({ id }).delete();
|
await knex('saved_list_views').where({ id }).delete();
|
||||||
return { deleted: true };
|
return { deleted: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Sharing via record_shares ────────────────────────────────────────────
|
||||||
|
|
||||||
|
async getShares(tenantId: string, userId: string, viewId: string) {
|
||||||
|
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
|
||||||
|
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
|
||||||
|
|
||||||
|
const view = await knex('saved_list_views').where({ id: viewId }).first();
|
||||||
|
if (!view) throw new NotFoundException('Saved view not found');
|
||||||
|
if (view.user_id !== userId) {
|
||||||
|
throw new ForbiddenException('Only the view owner can manage sharing');
|
||||||
|
}
|
||||||
|
|
||||||
|
const objectDefId = await this.getSavedViewObjectDefId(knex);
|
||||||
|
|
||||||
|
const shares = await RecordShare.query(knex)
|
||||||
|
.where({ objectDefinitionId: objectDefId, recordId: viewId })
|
||||||
|
.whereNull('revokedAt')
|
||||||
|
.where(builder => {
|
||||||
|
builder.whereNull('expiresAt').orWhere('expiresAt', '>', new Date());
|
||||||
|
})
|
||||||
|
.withGraphFetched('[granteeUser]')
|
||||||
|
.orderBy('createdAt', 'desc');
|
||||||
|
|
||||||
|
return shares;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createShare(
|
||||||
|
tenantId: string,
|
||||||
|
userId: string,
|
||||||
|
viewId: string,
|
||||||
|
dto: { granteeUserId: string; canRead: boolean; canEdit: boolean; canDelete: boolean; expiresAt?: string },
|
||||||
|
) {
|
||||||
|
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
|
||||||
|
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
|
||||||
|
|
||||||
|
const view = await knex('saved_list_views').where({ id: viewId }).first();
|
||||||
|
if (!view) throw new NotFoundException('Saved view not found');
|
||||||
|
if (view.user_id !== userId) {
|
||||||
|
throw new ForbiddenException('Only the view owner can share it');
|
||||||
|
}
|
||||||
|
if (dto.granteeUserId === userId) {
|
||||||
|
throw new BadRequestException('Cannot share a view with yourself');
|
||||||
|
}
|
||||||
|
|
||||||
|
const objectDefId = await this.getSavedViewObjectDefId(knex);
|
||||||
|
|
||||||
|
// Upsert: if non-revoked share already exists for this grantee, update it
|
||||||
|
const existing = await RecordShare.query(knex)
|
||||||
|
.where({
|
||||||
|
objectDefinitionId: objectDefId,
|
||||||
|
recordId: viewId,
|
||||||
|
granteeUserId: dto.granteeUserId,
|
||||||
|
})
|
||||||
|
.whereNull('revokedAt')
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await RecordShare.query(knex)
|
||||||
|
.patchAndFetchById(existing.id, {
|
||||||
|
accessLevel: {
|
||||||
|
canRead: dto.canRead,
|
||||||
|
canEdit: dto.canEdit,
|
||||||
|
canDelete: dto.canDelete,
|
||||||
|
},
|
||||||
|
expiresAt: dto.expiresAt
|
||||||
|
? (knex.raw('?', [new Date(dto.expiresAt).toISOString().slice(0, 19).replace('T', ' ')]) as any)
|
||||||
|
: null,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
return RecordShare.query(knex)
|
||||||
|
.findById(existing.id)
|
||||||
|
.withGraphFetched('[granteeUser]');
|
||||||
|
}
|
||||||
|
|
||||||
|
const share = await RecordShare.query(knex).insertAndFetch({
|
||||||
|
objectDefinitionId: objectDefId,
|
||||||
|
recordId: viewId,
|
||||||
|
granteeUserId: dto.granteeUserId,
|
||||||
|
grantedByUserId: userId,
|
||||||
|
accessLevel: {
|
||||||
|
canRead: dto.canRead,
|
||||||
|
canEdit: dto.canEdit,
|
||||||
|
canDelete: dto.canDelete,
|
||||||
|
},
|
||||||
|
expiresAt: dto.expiresAt
|
||||||
|
? (knex.raw('?', [new Date(dto.expiresAt).toISOString().slice(0, 19).replace('T', ' ')]) as any)
|
||||||
|
: null,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
return RecordShare.query(knex)
|
||||||
|
.findById(share.id)
|
||||||
|
.withGraphFetched('[granteeUser]');
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeShare(tenantId: string, userId: string, viewId: string, shareId: string) {
|
||||||
|
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
|
||||||
|
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
|
||||||
|
|
||||||
|
const view = await knex('saved_list_views').where({ id: viewId }).first();
|
||||||
|
if (!view) throw new NotFoundException('Saved view not found');
|
||||||
|
if (view.user_id !== userId) {
|
||||||
|
throw new ForbiddenException('Only the view owner can manage sharing');
|
||||||
|
}
|
||||||
|
|
||||||
|
const share = await RecordShare.query(knex).findById(shareId);
|
||||||
|
if (!share) throw new NotFoundException('Share not found');
|
||||||
|
|
||||||
|
// Soft-revoke
|
||||||
|
await RecordShare.query(knex)
|
||||||
|
.findById(shareId)
|
||||||
|
.patch({ revokedAt: knex.fn.now() } as any);
|
||||||
|
|
||||||
|
return { revoked: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Serialisation ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private deserialize(row: any, currentUserId: string) {
|
private deserialize(row: any, currentUserId: string) {
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
|
|||||||
@@ -186,6 +186,8 @@ interface Props {
|
|||||||
objectApiName: string;
|
objectApiName: string;
|
||||||
recordId: string;
|
recordId: string;
|
||||||
ownerId?: string;
|
ownerId?: string;
|
||||||
|
/** Optional base URL override for shares API. Defaults to /runtime/objects/{objectApiName}/records/{recordId}/shares */
|
||||||
|
basePath?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>();
|
const props = defineProps<Props>();
|
||||||
@@ -193,6 +195,11 @@ const props = defineProps<Props>();
|
|||||||
const { api } = useApi();
|
const { api } = useApi();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
/** Computed base path for all share API calls */
|
||||||
|
const sharesBasePath = computed(() =>
|
||||||
|
props.basePath || `/runtime/objects/${props.objectApiName}/records/${props.recordId}/shares`
|
||||||
|
);
|
||||||
|
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const sharing = ref(false);
|
const sharing = ref(false);
|
||||||
const removing = ref<string | null>(null);
|
const removing = ref<string | null>(null);
|
||||||
@@ -236,9 +243,7 @@ const loadShares = async () => {
|
|||||||
try {
|
try {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = null;
|
error.value = null;
|
||||||
const response = await api.get(
|
const response = await api.get(sharesBasePath.value);
|
||||||
`/runtime/objects/${props.objectApiName}/records/${props.recordId}/shares`
|
|
||||||
);
|
|
||||||
shares.value = response || [];
|
shares.value = response || [];
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error('Failed to load shares:', e);
|
console.error('Failed to load shares:', e);
|
||||||
@@ -286,7 +291,7 @@ const createShare = async () => {
|
|||||||
console.log('Final payload:', payload);
|
console.log('Final payload:', payload);
|
||||||
|
|
||||||
await api.post(
|
await api.post(
|
||||||
`/runtime/objects/${props.objectApiName}/records/${props.recordId}/shares`,
|
sharesBasePath.value,
|
||||||
payload
|
payload
|
||||||
);
|
);
|
||||||
toast.success('Record shared successfully');
|
toast.success('Record shared successfully');
|
||||||
@@ -313,7 +318,7 @@ const removeShare = async (shareId: string) => {
|
|||||||
try {
|
try {
|
||||||
removing.value = shareId;
|
removing.value = shareId;
|
||||||
await api.delete(
|
await api.delete(
|
||||||
`/runtime/objects/${props.objectApiName}/records/${props.recordId}/shares/${shareId}`
|
`${sharesBasePath.value}/${shareId}`
|
||||||
);
|
);
|
||||||
toast.success('Share removed successfully');
|
toast.success('Share removed successfully');
|
||||||
await loadShares();
|
await loadShares();
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed, nextTick } from 'vue'
|
||||||
import {
|
import {
|
||||||
Sheet,
|
Sheet,
|
||||||
SheetContent,
|
SheetContent,
|
||||||
@@ -11,9 +11,7 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Separator } from '@/components/ui/separator'
|
import { Separator } from '@/components/ui/separator'
|
||||||
import { Switch } from '@/components/ui/switch'
|
import { Pencil, Trash2, Users, Check, X, ChevronLeft } from 'lucide-vue-next'
|
||||||
import { Label } from '@/components/ui/label'
|
|
||||||
import { Pencil, Trash2, Users, Check, X } from 'lucide-vue-next'
|
|
||||||
import type { SavedView, UpdateSavedViewPayload } from '@/composables/useSavedViews'
|
import type { SavedView, UpdateSavedViewPayload } from '@/composables/useSavedViews'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -38,8 +36,11 @@ const editingId = ref<string | null>(null)
|
|||||||
const editName = ref('')
|
const editName = ref('')
|
||||||
const deletingId = ref<string | null>(null)
|
const deletingId = ref<string | null>(null)
|
||||||
|
|
||||||
|
// Sharing sub-view: when set, renders RecordSharing for this view
|
||||||
|
const sharingView = ref<SavedView | null>(null)
|
||||||
|
|
||||||
const ownViews = computed(() => props.views.filter(v => v.isOwner))
|
const ownViews = computed(() => props.views.filter(v => v.isOwner))
|
||||||
const sharedViews = computed(() => props.views.filter(v => !v.isOwner && v.isShared))
|
const sharedViews = computed(() => props.views.filter(v => !v.isOwner))
|
||||||
|
|
||||||
function startEdit(view: SavedView) {
|
function startEdit(view: SavedView) {
|
||||||
editingId.value = view.id
|
editingId.value = view.id
|
||||||
@@ -59,8 +60,12 @@ function commitEdit(view: SavedView) {
|
|||||||
cancelEdit()
|
cancelEdit()
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleShare(view: SavedView) {
|
function openSharing(view: SavedView) {
|
||||||
emit('update-view', view.id, { isShared: !view.isShared })
|
sharingView.value = view
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSharing() {
|
||||||
|
sharingView.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
function confirmDelete(view: SavedView) {
|
function confirmDelete(view: SavedView) {
|
||||||
@@ -79,127 +84,150 @@ function executeDelete(view: SavedView) {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Sheet :open="open" @update:open="emit('update:open', $event)">
|
<Sheet :open="open" @update:open="emit('update:open', $event)">
|
||||||
<SheetContent class="w-[420px] sm:w-[480px] overflow-y-auto">
|
<SheetContent class="w-[420px] sm:w-[520px] overflow-y-auto">
|
||||||
<SheetHeader class="mb-4">
|
|
||||||
<SheetTitle>{{ objectLabel }} — Saved Views</SheetTitle>
|
|
||||||
<SheetDescription>
|
|
||||||
Manage your saved searches. Shared views are visible to all users in your workspace.
|
|
||||||
</SheetDescription>
|
|
||||||
</SheetHeader>
|
|
||||||
|
|
||||||
<!-- Own Views -->
|
<!-- ─── Sharing sub-view ─── -->
|
||||||
<section>
|
<template v-if="sharingView">
|
||||||
<p class="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-2">
|
<SheetHeader class="mb-4">
|
||||||
My Views
|
<div class="flex items-center gap-2">
|
||||||
</p>
|
<Button size="icon" variant="ghost" class="h-7 w-7 -ml-1" @click="closeSharing">
|
||||||
|
<ChevronLeft class="h-4 w-4" />
|
||||||
<div v-if="ownViews.length === 0" class="text-sm text-muted-foreground py-3">
|
</Button>
|
||||||
You have no saved views yet. Run a search and click <strong>Save view</strong>.
|
<div>
|
||||||
</div>
|
<SheetTitle>Share "{{ sharingView.name }}"</SheetTitle>
|
||||||
|
<SheetDescription>
|
||||||
<ul class="space-y-1">
|
Grant access to specific users for this saved view.
|
||||||
<li
|
</SheetDescription>
|
||||||
v-for="view in ownViews"
|
|
||||||
:key="view.id"
|
|
||||||
class="group rounded-md border bg-card px-3 py-2"
|
|
||||||
>
|
|
||||||
<!-- Confirm delete row -->
|
|
||||||
<div v-if="deletingId === view.id" class="flex items-center gap-2">
|
|
||||||
<span class="flex-1 text-sm text-destructive">Delete "{{ view.name }}"?</span>
|
|
||||||
<Button size="sm" variant="destructive" @click="executeDelete(view)">Delete</Button>
|
|
||||||
<Button size="sm" variant="outline" @click="cancelDelete">Cancel</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
<!-- Edit name row -->
|
<RecordSharing
|
||||||
<div v-else-if="editingId === view.id" class="flex items-center gap-2">
|
object-api-name="SavedListView"
|
||||||
<Input
|
:record-id="sharingView.id"
|
||||||
v-model="editName"
|
:owner-id="sharingView.userId"
|
||||||
class="h-7 flex-1 text-sm"
|
:base-path="`/saved-views/${sharingView.id}/shares`"
|
||||||
@keyup.enter="commitEdit(view)"
|
/>
|
||||||
@keyup.escape="cancelEdit"
|
</template>
|
||||||
autofocus
|
|
||||||
/>
|
|
||||||
<Button size="icon" variant="ghost" class="h-7 w-7" @click="commitEdit(view)">
|
|
||||||
<Check class="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
<Button size="icon" variant="ghost" class="h-7 w-7" @click="cancelEdit">
|
|
||||||
<X class="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Normal row -->
|
<!-- ─── Main view list ─── -->
|
||||||
<div v-else class="flex items-center gap-2 min-h-[28px]">
|
<template v-else>
|
||||||
<!-- View name (click to apply) -->
|
<SheetHeader class="mb-4">
|
||||||
<button
|
<SheetTitle>{{ objectLabel }} — Saved Views</SheetTitle>
|
||||||
class="flex-1 text-left text-sm truncate hover:text-primary transition-colors"
|
<SheetDescription>
|
||||||
:class="{ 'font-medium text-primary': activeViewId === view.id }"
|
Manage your saved searches. Share views with specific users from your workspace.
|
||||||
@click="emit('apply-view', view); emit('update:open', false)"
|
</SheetDescription>
|
||||||
>
|
</SheetHeader>
|
||||||
{{ view.name }}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Shared badge -->
|
<!-- Own Views -->
|
||||||
<Badge v-if="view.isShared" variant="secondary" class="text-[10px] px-1.5 py-0">
|
|
||||||
<Users class="h-3 w-3 mr-1" />Shared
|
|
||||||
</Badge>
|
|
||||||
|
|
||||||
<!-- Actions (visible on hover) -->
|
|
||||||
<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
||||||
<Button size="icon" variant="ghost" class="h-6 w-6" title="Rename" @click="startEdit(view)">
|
|
||||||
<Pencil class="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="icon"
|
|
||||||
variant="ghost"
|
|
||||||
class="h-6 w-6"
|
|
||||||
:title="view.isShared ? 'Unshare' : 'Share with team'"
|
|
||||||
@click="toggleShare(view)"
|
|
||||||
>
|
|
||||||
<Users class="h-3 w-3" :class="{ 'text-primary': view.isShared }" />
|
|
||||||
</Button>
|
|
||||||
<Button size="icon" variant="ghost" class="h-6 w-6 text-destructive hover:text-destructive" title="Delete" @click="confirmDelete(view)">
|
|
||||||
<Trash2 class="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Description tooltip -->
|
|
||||||
<p
|
|
||||||
v-if="view.description && editingId !== view.id && deletingId !== view.id"
|
|
||||||
class="text-xs text-muted-foreground mt-1 truncate"
|
|
||||||
>
|
|
||||||
{{ view.description }}
|
|
||||||
</p>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Shared views by others -->
|
|
||||||
<template v-if="sharedViews.length > 0">
|
|
||||||
<Separator class="my-4" />
|
|
||||||
<section>
|
<section>
|
||||||
<p class="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-2">
|
<p class="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-2">
|
||||||
Shared with me
|
My Views
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<div v-if="ownViews.length === 0" class="text-sm text-muted-foreground py-3">
|
||||||
|
You have no saved views yet. Run a search and click <strong>Save view</strong>.
|
||||||
|
</div>
|
||||||
|
|
||||||
<ul class="space-y-1">
|
<ul class="space-y-1">
|
||||||
<li
|
<li
|
||||||
v-for="view in sharedViews"
|
v-for="view in ownViews"
|
||||||
:key="view.id"
|
:key="view.id"
|
||||||
class="rounded-md border bg-card px-3 py-2"
|
class="group rounded-md border bg-card px-3 py-2"
|
||||||
>
|
>
|
||||||
<button
|
<!-- Confirm delete row -->
|
||||||
class="w-full text-left text-sm truncate hover:text-primary transition-colors"
|
<div v-if="deletingId === view.id" class="flex items-center gap-2">
|
||||||
:class="{ 'font-medium text-primary': activeViewId === view.id }"
|
<span class="flex-1 text-sm text-destructive">Delete "{{ view.name }}"?</span>
|
||||||
@click="emit('apply-view', view); emit('update:open', false)"
|
<Button size="sm" variant="destructive" @click="executeDelete(view)">Delete</Button>
|
||||||
|
<Button size="sm" variant="outline" @click="cancelDelete">Cancel</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit name row -->
|
||||||
|
<div v-else-if="editingId === view.id" class="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
v-model="editName"
|
||||||
|
class="h-7 flex-1 text-sm"
|
||||||
|
@keyup.enter="commitEdit(view)"
|
||||||
|
@keyup.escape="cancelEdit"
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
<Button size="icon" variant="ghost" class="h-7 w-7" @click="commitEdit(view)">
|
||||||
|
<Check class="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button size="icon" variant="ghost" class="h-7 w-7" @click="cancelEdit">
|
||||||
|
<X class="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Normal row -->
|
||||||
|
<div v-else class="flex items-center gap-2 min-h-[28px]">
|
||||||
|
<!-- View name (click to apply) -->
|
||||||
|
<button
|
||||||
|
class="flex-1 text-left text-sm truncate hover:text-primary transition-colors"
|
||||||
|
:class="{ 'font-medium text-primary': activeViewId === view.id }"
|
||||||
|
@click="emit('apply-view', view); emit('update:open', false)"
|
||||||
|
>
|
||||||
|
{{ view.name }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Actions (visible on hover) -->
|
||||||
|
<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<Button size="icon" variant="ghost" class="h-6 w-6" title="Rename" @click="startEdit(view)">
|
||||||
|
<Pencil class="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
class="h-6 w-6"
|
||||||
|
title="Share"
|
||||||
|
@click="openSharing(view)"
|
||||||
|
>
|
||||||
|
<Users class="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
<Button size="icon" variant="ghost" class="h-6 w-6 text-destructive hover:text-destructive" title="Delete" @click="confirmDelete(view)">
|
||||||
|
<Trash2 class="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
<p
|
||||||
|
v-if="view.description && editingId !== view.id && deletingId !== view.id"
|
||||||
|
class="text-xs text-muted-foreground mt-1 truncate"
|
||||||
>
|
>
|
||||||
{{ view.name }}
|
|
||||||
</button>
|
|
||||||
<p v-if="view.description" class="text-xs text-muted-foreground mt-1 truncate">
|
|
||||||
{{ view.description }}
|
{{ view.description }}
|
||||||
</p>
|
</p>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Shared views by others -->
|
||||||
|
<template v-if="sharedViews.length > 0">
|
||||||
|
<Separator class="my-4" />
|
||||||
|
<section>
|
||||||
|
<p class="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-2">
|
||||||
|
Shared with me
|
||||||
|
</p>
|
||||||
|
<ul class="space-y-1">
|
||||||
|
<li
|
||||||
|
v-for="view in sharedViews"
|
||||||
|
:key="view.id"
|
||||||
|
class="rounded-md border bg-card px-3 py-2"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="w-full text-left text-sm truncate hover:text-primary transition-colors"
|
||||||
|
:class="{ 'font-medium text-primary': activeViewId === view.id }"
|
||||||
|
@click="emit('apply-view', view); emit('update:open', false)"
|
||||||
|
>
|
||||||
|
{{ view.name }}
|
||||||
|
</button>
|
||||||
|
<p v-if="view.description" class="text-xs text-muted-foreground mt-1 truncate">
|
||||||
|
{{ view.description }}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ export interface CreateSavedViewPayload {
|
|||||||
|
|
||||||
export interface UpdateSavedViewPayload {
|
export interface UpdateSavedViewPayload {
|
||||||
name?: string
|
name?: string
|
||||||
isShared?: boolean
|
|
||||||
filters?: SavedViewFilter[]
|
filters?: SavedViewFilter[]
|
||||||
sort?: SavedViewSort | null
|
sort?: SavedViewSort | null
|
||||||
description?: string
|
description?: string
|
||||||
|
|||||||
Reference in New Issue
Block a user