Skip to content
Merged
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
46 changes: 46 additions & 0 deletions src/__testing__/nullTime.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { getDeletedAtTime, isSoftDeleted } from '../utils/nullTime';

describe('isSoftDeleted', () => {
it('treats null / undefined as NOT soft-deleted (canonical live-row shape)', () => {
expect(isSoftDeleted(null)).toBe(false);
expect(isSoftDeleted(undefined)).toBe(false);
});

it('does not throw on null (regression: Invite User widget crash)', () => {
// The original crash was `null is not an object (evaluating 't?.deletedAt.Valid')`.
expect(() => isSoftDeleted(null)).not.toThrow();
});

it('treats a non-empty timestamp string as soft-deleted (canonical deleted-row shape)', () => {
expect(isSoftDeleted('2026-07-14T20:33:05Z')).toBe(true);
});

it('treats an empty / whitespace string as NOT soft-deleted', () => {
expect(isSoftDeleted('')).toBe(false);
expect(isSoftDeleted(' ')).toBe(false);
});

it('honors the legacy Go sql.NullTime object shape', () => {
expect(isSoftDeleted({ Valid: true, Time: '2026-07-14T20:33:05Z' })).toBe(true);
expect(isSoftDeleted({ Valid: false, Time: '0001-01-01T00:00:00Z' })).toBe(false);
expect(isSoftDeleted({ Valid: false })).toBe(false);
});
});

describe('getDeletedAtTime', () => {
it('returns undefined when not soft-deleted', () => {
expect(getDeletedAtTime(null)).toBeUndefined();
expect(getDeletedAtTime(undefined)).toBeUndefined();
expect(getDeletedAtTime({ Valid: false, Time: '0001-01-01T00:00:00Z' })).toBeUndefined();
});

it('returns the string itself for the canonical deleted-row shape', () => {
expect(getDeletedAtTime('2026-07-14T20:33:05Z')).toBe('2026-07-14T20:33:05Z');
});

it('returns the .Time member for the legacy object shape', () => {
expect(getDeletedAtTime({ Valid: true, Time: '2026-07-14T20:33:05Z' })).toBe(
'2026-07-14T20:33:05Z'
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,13 @@ import React, { Dispatch, SetStateAction, useEffect, useMemo, useState } from 'r
import { Chip, CircularProgress, TextField, Tooltip } from '../../../base';
import { iconSmall } from '../../../constants/iconsSizes';
import { CloseIcon } from '../../../icons';
import { DeletedAt, isSoftDeleted } from '../../../utils/nullTime';

interface Team {
id: string;
ID: string;
name: string;
deletedAt?: {
Valid: boolean;
} | null;
deletedAt?: DeletedAt;
}

interface TeamSearchFieldProps {
Expand Down Expand Up @@ -156,7 +155,7 @@ const TeamSearchField: React.FC<TeamSearchFieldProps> = ({
/>
)}
renderOption={(props, option) => {
if (!option.deletedAt?.Valid) {
if (!isSoftDeleted(option.deletedAt)) {
return (
<Box
component="li"
Expand Down
9 changes: 6 additions & 3 deletions src/custom/TeamTable/TeamTableConfiguration.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useState } from 'react';
import { DeleteIcon, EditIcon } from '../../icons';
import LogoutIcon from '../../icons/Logout/LogOutIcon';
import { CHARCOAL, useTheme } from '../../theme';
import { isSoftDeleted } from '../../utils/nullTime';
import { CustomTooltip } from '../CustomTooltip';
import { FormatId } from '../FormatId';
import { ConditionalTooltip } from '../Helpers/CondtionalTooltip';
Expand Down Expand Up @@ -210,7 +211,8 @@ export default function TeamTableConfiguration({
sort: false,
searchable: false,
customBodyRender: (_: string, tableMeta: MUIDataTableMeta) => {
if (bulkSelect || tableMeta.rowData[4].Valid) {
// rowData index 6 is the `deletedAt` column (see colViews/columns order).
if (bulkSelect || isSoftDeleted(tableMeta.rowData[6])) {
return (
<TableIconsDisabledContainer>
<EditIcon
Expand Down Expand Up @@ -367,7 +369,7 @@ export default function TeamTableConfiguration({
}
},
isRowSelectable: (dataIndx: number) => {
if (teams[dataIndx]['deletedAt'].Valid === true) return false;
if (isSoftDeleted(teams[dataIndx]['deletedAt'])) return false;
return true;
},
setRowProps: (row: any, rowIndex: number, tableState: any) => {
Expand All @@ -382,7 +384,8 @@ export default function TeamTableConfiguration({
};
}

if (row[6].Valid) {
// row index 6 is the `deletedAt` column (see colViews/columns order).
if (isSoftDeleted(row[6])) {
return {
style: {
backgroundColor: theme.palette.icon.disabled
Expand Down
5 changes: 3 additions & 2 deletions src/custom/UserSearchField/UserSearchField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useDebounce } from 'use-debounce';
import { Avatar, Box, Chip, Grid2, TextField, Typography } from '../../base';
import { PersonIcon } from '../../icons/Person';
import { useTheme } from '../../theme';
import { DeletedAt, isSoftDeleted } from '../../utils/nullTime';

interface User {
id: string;
Expand All @@ -14,7 +15,7 @@ interface User {
lastName: string;
email: string;
avatarUrl?: string;
deletedAt?: { Valid: boolean };
deletedAt?: DeletedAt;
}

interface UserSearchFieldProps {
Expand Down Expand Up @@ -178,7 +179,7 @@ const UserShareSearch: React.FC<UserSearchFieldProps> = ({
</Box>
</Grid2>
<Grid2 size="grow">
{option.deletedAt?.Valid ? (
{isSoftDeleted(option.deletedAt) ? (
<Typography variant="body2" color="text.secondary">
{option.email} (deleted)
</Typography>
Expand Down
5 changes: 3 additions & 2 deletions src/custom/UserSearchField/UserSearchFieldInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,15 @@ import {

import { iconSmall } from '../../constants/iconsSizes';
import { CloseIcon, PersonIcon } from '../../icons';
import { DeletedAt, isSoftDeleted } from '../../utils/nullTime';

interface User {
userId: string;
firstName: string;
lastName: string;
email: string;
avatarUrl?: string;
deletedAt?: { Valid: boolean };
deletedAt?: DeletedAt;
deleted?: boolean;
}

Expand Down Expand Up @@ -110,7 +111,7 @@ const UserSearchField: React.FC<UserSearchFieldProps> = ({
if (!value) return;

const isDuplicate = localUsersData.some((user) => user.userId === value.userId);
const isDeleted = value.deletedAt?.Valid === true;
const isDeleted = isSoftDeleted(value.deletedAt);

if (isDuplicate || isDeleted) {
setError(isDuplicate ? 'User already selected' : 'User does not exist');
Expand Down
3 changes: 2 additions & 1 deletion src/custom/UsersTable/UsersTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import Github from '../../icons/Github/GithubIcon';
import Google from '../../icons/Google/GoogleIcon';
import LogoutIcon from '../../icons/Logout/LogOutIcon';
import { CHARCOAL, SistentThemeProviderWithoutBaseLine } from '../../theme';
import { isSoftDeleted } from '../../utils/nullTime';
import { useWindowDimensions } from '../Helpers/Dimension';
import {
ColView,
Expand Down Expand Up @@ -443,7 +444,7 @@ const UsersTable: React.FC<UsersTableProps> = ({
sort: false,
searchable: false,
customBodyRender: (_: string, tableMeta: MUIDataTableMeta) =>
getValidColumnValue(tableMeta.rowData, 'deletedAt', columns).Valid !== false ? (
isSoftDeleted(getValidColumnValue(tableMeta.rowData, 'deletedAt', columns)) ? (
<TableIconsDisabledContainer>
<EditIcon
style={{
Expand Down
6 changes: 3 additions & 3 deletions src/custom/Workspaces/WorkspaceCard.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useTheme } from '@mui/material';
import { Backdrop, CircularProgress, Grid2 } from '../../base';

import { getRelativeTime } from '../../utils';
import { DeletedAt, getRelativeTime, isSoftDeleted } from '../../utils';
import { FlipCard } from '../FlipCard';
import { RecordRow, RedirectButton, TransferButton } from './WorkspaceTransferButton';
import {
Expand Down Expand Up @@ -30,7 +30,7 @@ interface WorkspaceDetails {
id: number;
name: string;
description: string;
deletedAt: { Valid: boolean };
deletedAt: DeletedAt;
updatedAt: string;
createdAt: string;
}
Expand Down Expand Up @@ -162,7 +162,7 @@ const WorkspaceCard = ({
isEnvironmentsVisible,
isTeamsVisible
}: WorkspaceCardProps) => {
const deleted = workspaceDetails.deletedAt.Valid;
const deleted = isSoftDeleted(workspaceDetails.deletedAt);
return (
<FlipCard
disableFlip={selectedWorkspaces.includes(workspaceDetails.id) ? true : false}
Expand Down
13 changes: 4 additions & 9 deletions src/custom/Workspaces/helper.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { getFormatDate } from '../../utils';
import { DeletedAt, getDeletedAtTime, getFormatDate } from '../../utils';

/**
* Helper function to parse and format the value of a field representing a deletion timestamp in the provided data object.
Expand All @@ -7,14 +7,9 @@ import { getFormatDate } from '../../utils';
*/

export const DEFAULT_DATE = 'N/A'; // a constant to represent the default date value
export const parseDeletionTimestamp = (data: {
deletedAt: { Valid: boolean; Time: string | number | Date };
}) => {
if (data && data.deletedAt && data.deletedAt.Valid === true) {
return getFormatDate(data.deletedAt.Time as string);
} else {
return DEFAULT_DATE;
}
export const parseDeletionTimestamp = (data: { deletedAt: DeletedAt }) => {
const time = getDeletedAtTime(data?.deletedAt);
return time != null ? getFormatDate(time) : DEFAULT_DATE;
};

/**
Expand Down
10 changes: 4 additions & 6 deletions src/custom/Workspaces/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { DeletedAt } from '../../utils/nullTime';

export interface AssignmentHookResult<T> {
data: T[];
workspaceData: T[];
Expand All @@ -22,9 +24,7 @@ export interface Workspace {
metadata?: Record<string, string>;
createdAt: string;
updatedAt: string;
deletedAt: {
Valid: boolean;
};
deletedAt: DeletedAt;
}

export interface Environment {
Expand All @@ -45,7 +45,5 @@ export interface Team {
// (currently aliased to `Team.name`; flipping here without the server rename
// would break the wire contract). Deferred from Phase 2.K cascade.
team_name: string;
deletedAt: {
Valid: boolean;
};
deletedAt: DeletedAt;
}
1 change: 1 addition & 0 deletions src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './components';
export * from './nullTime';
export * from './time.utils';
56 changes: 56 additions & 0 deletions src/utils/nullTime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/**
* Utilities for the soft-delete timestamp (`deletedAt`) that Meshery/Layer5
* APIs attach to soft-deletable entities (teams, users, workspaces, ...).
*
* The value arrives in more than one shape depending on which endpoint and
* serializer produced it:
*
* - `null` / `undefined` — the entity is NOT soft-deleted. This is the
* canonical `meshery/schemas` wire shape: `core.NullTime` marshals a live
* row to JSON `null`.
* - an ISO-8601 timestamp `string` — the entity IS soft-deleted; the string
* is the deletion time. This is the canonical schema shape for a deleted
* row (`type: string, format: date-time`).
* - a `{ Valid: boolean; Time?: ... }` object — the legacy Go `sql.NullTime`
* JSON shape still emitted by some provider endpoints. Deleted iff `Valid`.
*
* Reading `.Valid` directly is unsafe: when `deletedAt` is `null` the access
* throws `null is not an object (evaluating '…deletedAt.Valid')` — the crash
* that took down the Invite User widget's team search. Route every soft-delete
* check through these helpers so no shape can crash the UI.
*/

/** The legacy Go `sql.NullTime` JSON shape. */
export interface SqlNullTime {
Valid: boolean;
Time?: string | number | Date | null;
}

/** Every shape a `deletedAt` value may take on the wire. */
export type DeletedAt = string | SqlNullTime | null | undefined;

/**
* Returns `true` when a `deletedAt` value indicates the entity has been
* soft-deleted, tolerating every shape the value may take (null/undefined,
* timestamp string, or legacy `{ Valid, Time }` object).
*/
export const isSoftDeleted = (deletedAt: DeletedAt): boolean => {
if (deletedAt === null || deletedAt === undefined) return false;
if (typeof deletedAt === 'string') return deletedAt.trim().length > 0;
if (typeof deletedAt === 'object') return deletedAt.Valid === true;
return false;
};

/**
* Returns the deletion timestamp suitable for a date formatter when the entity
* is soft-deleted, otherwise `undefined`. Handles both the timestamp-string and
* legacy `{ Valid, Time }` object shapes.
*/
export const getDeletedAtTime = (deletedAt: DeletedAt): string | number | Date | undefined => {
if (!isSoftDeleted(deletedAt)) return undefined;
if (typeof deletedAt === 'string') return deletedAt;
if (deletedAt && typeof deletedAt === 'object') {
return deletedAt.Time ?? undefined;
}
return undefined;
};
3 changes: 2 additions & 1 deletion src/utils/permissions.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { VISIBILITY } from '../constants/constants';
import { DeletedAt } from './nullTime';

export interface User {
id: string;
Expand All @@ -7,7 +8,7 @@ export interface User {
lastName: string;
email: string;
avatarUrl?: string;
deletedAt?: { Valid: boolean };
deletedAt?: DeletedAt;
roleNames?: string[];
}

Expand Down
2 changes: 1 addition & 1 deletion src/utils/time.utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export const getFullFormattedTime = (date: string): string => {
* @param {string} date - ISO format date string
* @returns {string} Formatted date in "Month Day, Year" format (e.g. "Jan 1, 2025")
*/
export const getFormatDate = (date: string) => {
export const getFormatDate = (date: string | number | Date) => {
const options = { year: 'numeric' as const, month: 'short' as const, day: 'numeric' as const };
const formattedDate = new Date(date).toLocaleDateString('en-US', options);
return formattedDate;
Expand Down
Loading