R: Add ReadOnly wrapper for backend data
Some checks failed
Frontend CI / build (22.x) (push) Has been cancelled
Frontend CI / notify-failure (push) Has been cancelled

This commit is contained in:
Ivan 2025-04-30 12:31:30 +03:00
parent d90cf08b6c
commit b7358d3cc7
33 changed files with 178 additions and 63 deletions

View File

@ -8,6 +8,7 @@ import { type z, ZodError } from 'zod';
import { buildConstants } from '@/utils/build-constants'; import { buildConstants } from '@/utils/build-constants';
import { PARAMETER } from '@/utils/constants'; import { PARAMETER } from '@/utils/constants';
import { errorMsg } from '@/utils/labels'; import { errorMsg } from '@/utils/labels';
import { type RO } from '@/utils/meta';
import { extractErrorMessage } from '@/utils/utils'; import { extractErrorMessage } from '@/utils/utils';
export { AxiosError } from 'axios'; export { AxiosError } from 'axios';
@ -58,7 +59,7 @@ export function axiosGet<ResponseData>({ endpoint, options, schema }: IAxiosGetR
.get<ResponseData>(endpoint, options) .get<ResponseData>(endpoint, options)
.then(response => { .then(response => {
schema?.parse(response.data); schema?.parse(response.data);
return response.data; return response.data as RO<ResponseData>;
}) })
.catch((error: Error | AxiosError) => { .catch((error: Error | AxiosError) => {
// Note: Ignore cancellation errors // Note: Ignore cancellation errors
@ -81,7 +82,7 @@ export function axiosPost<RequestData, ResponseData = void>({
.then(response => { .then(response => {
schema?.parse(response.data); schema?.parse(response.data);
notifySuccess(response.data, request?.successMessage); notifySuccess(response.data, request?.successMessage);
return response.data; return response.data as RO<ResponseData>;
}) })
.catch((error: Error | AxiosError | ZodError) => { .catch((error: Error | AxiosError | ZodError) => {
notifyError(error); notifyError(error);
@ -100,7 +101,7 @@ export function axiosDelete<RequestData, ResponseData = void>({
.then(response => { .then(response => {
schema?.parse(response.data); schema?.parse(response.data);
notifySuccess(response.data, request?.successMessage); notifySuccess(response.data, request?.successMessage);
return response.data; return response.data as RO<ResponseData>;
}) })
.catch((error: Error | AxiosError | ZodError) => { .catch((error: Error | AxiosError | ZodError) => {
notifyError(error); notifyError(error);
@ -119,7 +120,7 @@ export function axiosPatch<RequestData, ResponseData = void>({
.then(response => { .then(response => {
schema?.parse(response.data); schema?.parse(response.data);
notifySuccess(response.data, request?.successMessage); notifySuccess(response.data, request?.successMessage);
return response.data; return response.data as RO<ResponseData>;
}) })
.catch((error: Error | AxiosError | ZodError) => { .catch((error: Error | AxiosError | ZodError) => {
notifyError(error); notifyError(error);

View File

@ -11,7 +11,7 @@ import { cn } from '../utils';
interface ComboBoxProps<Option> extends Styling { interface ComboBoxProps<Option> extends Styling {
id?: string; id?: string;
items?: Option[]; items?: readonly Option[];
value: Option | null; value: Option | null;
onChange: (newValue: Option | null) => void; onChange: (newValue: Option | null) => void;

View File

@ -4,6 +4,7 @@ import { type IOperationSchemaDTO } from '@/features/oss';
import { type IRSFormDTO } from '@/features/rsform'; import { type IRSFormDTO } from '@/features/rsform';
import { KEYS } from '@/backend/configuration'; import { KEYS } from '@/backend/configuration';
import { type RO } from '@/utils/meta';
import { libraryApi } from './api'; import { libraryApi } from './api';
import { type AccessPolicy, type ILibraryItem } from './types'; import { type AccessPolicy, type ILibraryItem } from './types';
@ -38,7 +39,7 @@ export const useSetAccessPolicy = () => {
client.setQueryData(rsKey, (prev: IRSFormDTO | undefined) => client.setQueryData(rsKey, (prev: IRSFormDTO | undefined) =>
!prev ? undefined : { ...prev, access_policy: variables.policy } !prev ? undefined : { ...prev, access_policy: variables.policy }
); );
client.setQueryData(libraryKey, (prev: ILibraryItem[] | undefined) => client.setQueryData(libraryKey, (prev: RO<ILibraryItem[]> | undefined) =>
prev?.map(item => (item.id === variables.itemID ? { ...item, access_policy: variables.policy } : item)) prev?.map(item => (item.id === variables.itemID ? { ...item, access_policy: variables.policy } : item))
); );
}, },

View File

@ -4,6 +4,7 @@ import { type IOperationSchemaDTO } from '@/features/oss';
import { type IRSFormDTO } from '@/features/rsform'; import { type IRSFormDTO } from '@/features/rsform';
import { KEYS } from '@/backend/configuration'; import { KEYS } from '@/backend/configuration';
import { type RO } from '@/utils/meta';
import { libraryApi } from './api'; import { libraryApi } from './api';
import { type ILibraryItem } from './types'; import { type ILibraryItem } from './types';
@ -38,7 +39,7 @@ export const useSetLocation = () => {
client.setQueryData(rsKey, (prev: IRSFormDTO | undefined) => client.setQueryData(rsKey, (prev: IRSFormDTO | undefined) =>
!prev ? undefined : { ...prev, location: variables.location } !prev ? undefined : { ...prev, location: variables.location }
); );
client.setQueryData(libraryKey, (prev: ILibraryItem[] | undefined) => client.setQueryData(libraryKey, (prev: RO<ILibraryItem[]> | undefined) =>
prev?.map(item => (item.id === variables.itemID ? { ...item, location: variables.location } : item)) prev?.map(item => (item.id === variables.itemID ? { ...item, location: variables.location } : item))
); );
}, },

View File

@ -4,6 +4,7 @@ import { type IOperationSchemaDTO } from '@/features/oss';
import { type IRSFormDTO } from '@/features/rsform'; import { type IRSFormDTO } from '@/features/rsform';
import { KEYS } from '@/backend/configuration'; import { KEYS } from '@/backend/configuration';
import { type RO } from '@/utils/meta';
import { libraryApi } from './api'; import { libraryApi } from './api';
import { type ILibraryItem } from './types'; import { type ILibraryItem } from './types';
@ -38,7 +39,7 @@ export const useSetOwner = () => {
client.setQueryData(rsKey, (prev: IRSFormDTO | undefined) => client.setQueryData(rsKey, (prev: IRSFormDTO | undefined) =>
!prev ? undefined : { ...prev, owner: variables.owner } !prev ? undefined : { ...prev, owner: variables.owner }
); );
client.setQueryData(libraryKey, (prev: ILibraryItem[] | undefined) => client.setQueryData(libraryKey, (prev: RO<ILibraryItem[]> | undefined) =>
prev?.map(item => (item.id === variables.itemID ? { ...item, owner: variables.owner } : item)) prev?.map(item => (item.id === variables.itemID ? { ...item, owner: variables.owner } : item))
); );
}, },

View File

@ -4,6 +4,7 @@ import { type IOperationSchemaDTO } from '@/features/oss';
import { type IRSFormDTO } from '@/features/rsform'; import { type IRSFormDTO } from '@/features/rsform';
import { KEYS } from '@/backend/configuration'; import { KEYS } from '@/backend/configuration';
import { type RO } from '@/utils/meta';
import { libraryApi } from './api'; import { libraryApi } from './api';
import { type ILibraryItem, type IUpdateLibraryItemDTO, LibraryItemType } from './types'; import { type ILibraryItem, type IUpdateLibraryItemDTO, LibraryItemType } from './types';
@ -20,7 +21,7 @@ export const useUpdateItem = () => {
data.item_type === LibraryItemType.RSFORM data.item_type === LibraryItemType.RSFORM
? KEYS.composite.rsItem({ itemID: data.id }) ? KEYS.composite.rsItem({ itemID: data.id })
: KEYS.composite.ossItem({ itemID: data.id }); : KEYS.composite.ossItem({ itemID: data.id });
client.setQueryData(libraryKey, (prev: ILibraryItem[] | undefined) => client.setQueryData(libraryKey, (prev: RO<ILibraryItem[]> | undefined) =>
prev?.map(item => (item.id === data.id ? data : item)) prev?.map(item => (item.id === data.id ? data : item))
); );
client.setQueryData(itemKey, (prev: IRSFormDTO | IOperationSchemaDTO | undefined) => client.setQueryData(itemKey, (prev: IRSFormDTO | IOperationSchemaDTO | undefined) =>

View File

@ -1,5 +1,7 @@
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { type RO } from '@/utils/meta';
import { type ILibraryItem } from './types'; import { type ILibraryItem } from './types';
import { useLibraryListKey } from './use-library'; import { useLibraryListKey } from './use-library';
@ -10,7 +12,7 @@ export function useUpdateTimestamp() {
updateTimestamp: (target: number) => updateTimestamp: (target: number) =>
client.setQueryData( client.setQueryData(
libraryKey, // libraryKey, //
(prev: ILibraryItem[] | undefined) => (prev: RO<ILibraryItem[]> | undefined) =>
prev?.map(item => (item.id === target ? { ...item, time_update: Date() } : item)) prev?.map(item => (item.id === target ? { ...item, time_update: Date() } : item))
) )
}; };

View File

@ -9,6 +9,7 @@ import { DataTable, type IConditionalStyle, type VisibilityState } from '@/compo
import { useWindowSize } from '@/hooks/use-window-size'; import { useWindowSize } from '@/hooks/use-window-size';
import { useFitHeight } from '@/stores/app-layout'; import { useFitHeight } from '@/stores/app-layout';
import { usePreferencesStore } from '@/stores/preferences'; import { usePreferencesStore } from '@/stores/preferences';
import { type RO } from '@/utils/meta';
import { type ILibraryItem, LibraryItemType } from '../../backend/types'; import { type ILibraryItem, LibraryItemType } from '../../backend/types';
import { useLibrarySearchStore } from '../../stores/library-search'; import { useLibrarySearchStore } from '../../stores/library-search';
@ -16,7 +17,7 @@ import { useLibrarySearchStore } from '../../stores/library-search';
import { useLibraryColumns } from './use-library-columns'; import { useLibraryColumns } from './use-library-columns';
interface TableLibraryItemsProps { interface TableLibraryItemsProps {
items: ILibraryItem[]; items: RO<ILibraryItem[]>;
} }
export function TableLibraryItems({ items }: TableLibraryItemsProps) { export function TableLibraryItems({ items }: TableLibraryItemsProps) {
@ -55,7 +56,7 @@ export function TableLibraryItems({ items }: TableLibraryItemsProps) {
<DataTable <DataTable
id='library_data' id='library_data'
columns={columns} columns={columns}
data={items} data={items as ILibraryItem[]}
headPosition='0' headPosition='0'
className={clsx('cc-scroll-y h-fit text-xs sm:text-sm border-b', folderMode && 'border-l')} className={clsx('cc-scroll-y h-fit text-xs sm:text-sm border-b', folderMode && 'border-l')}
style={{ maxHeight: tableHeight }} style={{ maxHeight: tableHeight }}

View File

@ -6,12 +6,13 @@ import { MiniButton } from '@/components/control';
import { createColumnHelper } from '@/components/data-table'; import { createColumnHelper } from '@/components/data-table';
import { IconFolderTree } from '@/components/icons'; import { IconFolderTree } from '@/components/icons';
import { useWindowSize } from '@/hooks/use-window-size'; import { useWindowSize } from '@/hooks/use-window-size';
import { type RO } from '@/utils/meta';
import { type ILibraryItem } from '../../backend/types'; import { type ILibraryItem } from '../../backend/types';
import { BadgeLocation } from '../../components/badge-location'; import { BadgeLocation } from '../../components/badge-location';
import { useLibrarySearchStore } from '../../stores/library-search'; import { useLibrarySearchStore } from '../../stores/library-search';
const columnHelper = createColumnHelper<ILibraryItem>(); const columnHelper = createColumnHelper<RO<ILibraryItem>>();
export function useLibraryColumns() { export function useLibraryColumns() {
const { isSmall } = useWindowSize(); const { isSmall } = useWindowSize();

View File

@ -5,6 +5,7 @@
import { type ILibraryItem } from '@/features/library'; import { type ILibraryItem } from '@/features/library';
import { Graph } from '@/models/graph'; import { Graph } from '@/models/graph';
import { type RO } from '@/utils/meta';
import { type IBlock, type IOperation, type IOperationSchema, type IOperationSchemaStats } from '../models/oss'; import { type IBlock, type IOperation, type IOperationSchema, type IOperationSchemaStats } from '../models/oss';
import { BLOCK_NODE_MIN_HEIGHT, BLOCK_NODE_MIN_WIDTH } from '../pages/oss-page/editor-oss-graph/graph/block-node'; import { BLOCK_NODE_MIN_HEIGHT, BLOCK_NODE_MIN_WIDTH } from '../pages/oss-page/editor-oss-graph/graph/block-node';
@ -19,9 +20,9 @@ export class OssLoader {
private operationByID = new Map<number, IOperation>(); private operationByID = new Map<number, IOperation>();
private blockByID = new Map<number, IBlock>(); private blockByID = new Map<number, IBlock>();
private schemaIDs: number[] = []; private schemaIDs: number[] = [];
private items: ILibraryItem[]; private items: RO<ILibraryItem[]>;
constructor(input: IOperationSchemaDTO, items: ILibraryItem[]) { constructor(input: RO<IOperationSchemaDTO>, items: RO<ILibraryItem[]>) {
this.oss = structuredClone(input) as IOperationSchema; this.oss = structuredClone(input) as IOperationSchema;
this.items = items; this.items = items;
} }

View File

@ -3,6 +3,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useUpdateTimestamp } from '@/features/library/backend/use-update-timestamp'; import { useUpdateTimestamp } from '@/features/library/backend/use-update-timestamp';
import { KEYS } from '@/backend/configuration'; import { KEYS } from '@/backend/configuration';
import { type RO } from '@/utils/meta';
import { ossApi } from './api'; import { ossApi } from './api';
import { type IOperationSchemaDTO, type IOssLayout } from './types'; import { type IOperationSchemaDTO, type IOssLayout } from './types';
@ -17,7 +18,7 @@ export const useUpdateLayout = () => {
updateTimestamp(variables.itemID); updateTimestamp(variables.itemID);
client.setQueryData( client.setQueryData(
ossApi.getOssQueryOptions({ itemID: variables.itemID }).queryKey, ossApi.getOssQueryOptions({ itemID: variables.itemID }).queryKey,
(prev: IOperationSchemaDTO | undefined) => (prev: RO<IOperationSchemaDTO> | undefined) =>
!prev !prev
? prev ? prev
: { : {

View File

@ -9,9 +9,10 @@ import { ComboBox } from '@/components/input/combo-box';
import { type Styling } from '@/components/props'; import { type Styling } from '@/components/props';
import { cn } from '@/components/utils'; import { cn } from '@/components/utils';
import { NoData } from '@/components/view'; import { NoData } from '@/components/view';
import { type RO } from '@/utils/meta';
import { labelOssItem } from '../labels'; import { labelOssItem } from '../labels';
import { type IBlock, type IOperation, type IOperationSchema } from '../models/oss'; import { type IOperationSchema, type IOssItem } from '../models/oss';
import { getItemID, isOperation } from '../models/oss-api'; import { getItemID, isOperation } from '../models/oss-api';
const SELECTION_CLEAR_TIMEOUT = 1000; const SELECTION_CLEAR_TIMEOUT = 1000;
@ -25,7 +26,7 @@ interface PickMultiOperationProps extends Styling {
disallowBlocks?: boolean; disallowBlocks?: boolean;
} }
const columnHelper = createColumnHelper<IOperation | IBlock>(); const columnHelper = createColumnHelper<RO<IOssItem>>();
export function PickContents({ export function PickContents({
rows, rows,
@ -40,7 +41,7 @@ export function PickContents({
const selectedItems = value const selectedItems = value
.map(itemID => (itemID > 0 ? schema.operationByID.get(itemID) : schema.blockByID.get(-itemID))) .map(itemID => (itemID > 0 ? schema.operationByID.get(itemID) : schema.blockByID.get(-itemID)))
.filter(item => item !== undefined); .filter(item => item !== undefined);
const [lastSelected, setLastSelected] = useState<IOperation | IBlock | null>(null); const [lastSelected, setLastSelected] = useState<RO<IOssItem> | null>(null);
const items = [ const items = [
...(disallowBlocks ? [] : schema.blocks.filter(item => !value.includes(-item.id) && !exclude?.includes(-item.id))), ...(disallowBlocks ? [] : schema.blocks.filter(item => !value.includes(-item.id) && !exclude?.includes(-item.id))),
...schema.operations.filter(item => !value.includes(item.id) && !exclude?.includes(item.id)) ...schema.operations.filter(item => !value.includes(item.id) && !exclude?.includes(item.id))
@ -50,7 +51,7 @@ export function PickContents({
onChange(value.filter(item => item !== target)); onChange(value.filter(item => item !== target));
} }
function handleSelect(target: IOperation | IBlock | null) { function handleSelect(target: RO<IOssItem> | null) {
if (target) { if (target) {
setLastSelected(target); setLastSelected(target);
onChange([...value, getItemID(target)]); onChange([...value, getItemID(target)]);

View File

@ -1,3 +1,5 @@
import { type RO } from '@/utils/meta';
import { OperationType } from './backend/types'; import { OperationType } from './backend/types';
import { import {
type IOperation, type IOperation,
@ -26,7 +28,7 @@ export function describeOperationType(itemType: OperationType): string {
} }
/** Generates error description for {@link ISubstitutionErrorDescription}. */ /** Generates error description for {@link ISubstitutionErrorDescription}. */
export function describeSubstitutionError(error: ISubstitutionErrorDescription): string { export function describeSubstitutionError(error: RO<ISubstitutionErrorDescription>): string {
switch (error.errorType) { switch (error.errorType) {
case SubstitutionErrorType.invalidIDs: case SubstitutionErrorType.invalidIDs:
return 'Ошибка в идентификаторах схем'; return 'Ошибка в идентификаторах схем';
@ -55,7 +57,7 @@ export function describeSubstitutionError(error: ISubstitutionErrorDescription):
} }
/** Retrieves label for {@link IOssItem}. */ /** Retrieves label for {@link IOssItem}. */
export function labelOssItem(item: IOssItem): string { export function labelOssItem(item: RO<IOssItem>): string {
if (isOperation(item)) { if (isOperation(item)) {
return `${(item as IOperation).alias}: ${item.title}`; return `${(item as IOperation).alias}: ${item.title}`;
} else { } else {

View File

@ -20,6 +20,7 @@ import {
} from '@/features/rsform/models/rslang-api'; } from '@/features/rsform/models/rslang-api';
import { infoMsg } from '@/utils/labels'; import { infoMsg } from '@/utils/labels';
import { type RO } from '@/utils/meta';
import { Graph } from '../../../models/graph'; import { Graph } from '../../../models/graph';
import { describeSubstitutionError } from '../labels'; import { describeSubstitutionError } from '../labels';
@ -29,17 +30,17 @@ import { type IOperationSchema, type IOssItem, SubstitutionErrorType } from './o
const STARTING_SUB_INDEX = 900; // max semantic index for starting substitution const STARTING_SUB_INDEX = 900; // max semantic index for starting substitution
/** Checks if element is {@link IOperation} or {@link IBlock}. */ /** Checks if element is {@link IOperation} or {@link IBlock}. */
export function isOperation(item: IOssItem | null): boolean { export function isOperation(item: RO<IOssItem> | null): boolean {
return !!item && 'arguments' in item; return !!item && 'arguments' in item;
} }
/** Extract contiguous ID of {@link IOperation} or {@link IBlock}. */ /** Extract contiguous ID of {@link IOperation} or {@link IBlock}. */
export function getItemID(item: IOssItem): number { export function getItemID(item: RO<IOssItem>): number {
return isOperation(item) ? item.id : -item.id; return isOperation(item) ? item.id : -item.id;
} }
/** Sorts library items relevant for the specified {@link IOperationSchema}. */ /** Sorts library items relevant for the specified {@link IOperationSchema}. */
export function sortItemsForOSS(oss: IOperationSchema, items: ILibraryItem[]): ILibraryItem[] { export function sortItemsForOSS(oss: IOperationSchema, items: readonly ILibraryItem[]): ILibraryItem[] {
const result = items.filter(item => item.location === oss.location); const result = items.filter(item => item.location === oss.location);
for (const item of items) { for (const item of items) {
if (item.visible && item.owner === oss.owner && !result.includes(item)) { if (item.visible && item.owner === oss.owner && !result.includes(item)) {

View File

@ -3,6 +3,7 @@
*/ */
import { Graph } from '@/models/graph'; import { Graph } from '@/models/graph';
import { type RO } from '@/utils/meta';
import { type IConstituenta, type IRSForm, type IRSFormStats } from '../models/rsform'; import { type IConstituenta, type IRSForm, type IRSFormStats } from '../models/rsform';
import { inferClass, inferStatus, inferTemplate, isBaseSet, isFunctional } from '../models/rsform-api'; import { inferClass, inferStatus, inferTemplate, isBaseSet, isFunctional } from '../models/rsform-api';
@ -23,7 +24,7 @@ export class RSFormLoader {
private cstByAlias = new Map<string, IConstituenta>(); private cstByAlias = new Map<string, IConstituenta>();
private cstByID = new Map<number, IConstituenta>(); private cstByID = new Map<number, IConstituenta>();
constructor(input: IRSFormDTO) { constructor(input: RO<IRSFormDTO>) {
this.schema = structuredClone(input) as IRSForm; this.schema = structuredClone(input) as IRSForm;
this.schema.version = input.version ?? 'latest'; this.schema.version = input.version ?? 'latest';
} }

View File

@ -6,6 +6,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { ModalForm } from '@/components/modal'; import { ModalForm } from '@/components/modal';
import { useDialogsStore } from '@/stores/dialogs'; import { useDialogsStore } from '@/stores/dialogs';
import { errorMsg } from '@/utils/labels'; import { errorMsg } from '@/utils/labels';
import { type RO } from '@/utils/meta';
import { import {
type IConstituentaBasicsDTO, type IConstituentaBasicsDTO,
@ -21,7 +22,7 @@ import { FormCreateCst } from './form-create-cst';
export interface DlgCreateCstProps { export interface DlgCreateCstProps {
initial: ICreateConstituentaDTO; initial: ICreateConstituentaDTO;
schema: IRSForm; schema: IRSForm;
onCreate: (data: IConstituentaBasicsDTO) => void; onCreate: (data: RO<IConstituentaBasicsDTO>) => void;
} }
export function DlgCreateCst() { export function DlgCreateCst() {

View File

@ -10,6 +10,7 @@ import { Loader } from '@/components/loader';
import { ModalForm } from '@/components/modal'; import { ModalForm } from '@/components/modal';
import { TabLabel, TabList, TabPanel, Tabs } from '@/components/tabs'; import { TabLabel, TabList, TabPanel, Tabs } from '@/components/tabs';
import { useDialogsStore } from '@/stores/dialogs'; import { useDialogsStore } from '@/stores/dialogs';
import { type RO } from '@/utils/meta';
import { import {
CstType, CstType,
@ -28,7 +29,7 @@ import { TemplateState } from './template-state';
export interface DlgCstTemplateProps { export interface DlgCstTemplateProps {
schema: IRSForm; schema: IRSForm;
onCreate: (data: IConstituentaBasicsDTO) => void; onCreate: (data: RO<IConstituentaBasicsDTO>) => void;
insertAfter?: number; insertAfter?: number;
} }

View File

@ -4,6 +4,7 @@ import { useEffect } from 'react';
import { type Edge, MarkerType, type Node, useEdgesState, useNodesState } from 'reactflow'; import { type Edge, MarkerType, type Node, useEdgesState, useNodesState } from 'reactflow';
import { DiagramFlow } from '@/components/flow/diagram-flow'; import { DiagramFlow } from '@/components/flow/diagram-flow';
import { type RO } from '@/utils/meta';
import { type SyntaxTree } from '../../models/rslang'; import { type SyntaxTree } from '../../models/rslang';
@ -22,7 +23,7 @@ const flowOptions = {
} as const; } as const;
interface ASTFlowProps { interface ASTFlowProps {
data: SyntaxTree; data: RO<SyntaxTree>;
onNodeEnter: (node: Node) => void; onNodeEnter: (node: Node) => void;
onNodeLeave: (node: Node) => void; onNodeLeave: (node: Node) => void;
onChangeDragging: (value: boolean) => void; onChangeDragging: (value: boolean) => void;

View File

@ -9,6 +9,7 @@ import { HelpTopic } from '@/features/help';
import { ModalView } from '@/components/modal'; import { ModalView } from '@/components/modal';
import { useDialogsStore } from '@/stores/dialogs'; import { useDialogsStore } from '@/stores/dialogs';
import { type RO } from '@/utils/meta';
import { type SyntaxTree } from '../../models/rslang'; import { type SyntaxTree } from '../../models/rslang';
@ -17,7 +18,7 @@ import { ASTFlow } from './ast-flow';
const NODE_POPUP_DELAY = 100; const NODE_POPUP_DELAY = 100;
export interface DlgShowASTProps { export interface DlgShowASTProps {
syntaxTree: SyntaxTree; syntaxTree: RO<SyntaxTree>;
expression: string; expression: string;
} }

View File

@ -8,6 +8,7 @@ import { HelpTopic } from '@/features/help';
import { ModalView } from '@/components/modal'; import { ModalView } from '@/components/modal';
import { useDialogsStore } from '@/stores/dialogs'; import { useDialogsStore } from '@/stores/dialogs';
import { errorMsg } from '@/utils/labels'; import { errorMsg } from '@/utils/labels';
import { type RO } from '@/utils/meta';
import { type ITypeInfo } from '../../models/rslang'; import { type ITypeInfo } from '../../models/rslang';
import { TypificationGraph } from '../../models/typification-graph'; import { TypificationGraph } from '../../models/typification-graph';
@ -15,7 +16,7 @@ import { TypificationGraph } from '../../models/typification-graph';
import { MGraphFlow } from './mgraph-flow'; import { MGraphFlow } from './mgraph-flow';
export interface DlgShowTypeGraphProps { export interface DlgShowTypeGraphProps {
items: ITypeInfo[]; items: RO<ITypeInfo[]>;
} }
export function DlgShowTypeGraph() { export function DlgShowTypeGraph() {

View File

@ -3,6 +3,7 @@
*/ */
import { PARAMETER } from '@/utils/constants'; import { PARAMETER } from '@/utils/constants';
import { type RO } from '@/utils/meta';
import { prepareTooltip } from '@/utils/utils'; import { prepareTooltip } from '@/utils/utils';
import { type IVersionInfo } from '../library/backend/types'; import { type IVersionInfo } from '../library/backend/types';
@ -18,7 +19,7 @@ import { type GraphColoring } from './stores/term-graph';
/** /**
* Generates description for {@link IConstituenta}. * Generates description for {@link IConstituenta}.
*/ */
export function describeConstituenta(cst: IConstituenta): string { export function describeConstituenta(cst: RO<IConstituenta>): string {
if (cst.cst_type === CstType.STRUCTURED) { if (cst.cst_type === CstType.STRUCTURED) {
return ( return (
cst.term_resolved || cst.term_resolved ||
@ -43,7 +44,7 @@ export function describeConstituenta(cst: IConstituenta): string {
/** /**
* Generates description for term of a given {@link IConstituenta}. * Generates description for term of a given {@link IConstituenta}.
*/ */
export function describeConstituentaTerm(cst: IConstituenta | null): string { export function describeConstituentaTerm(cst: RO<IConstituenta> | null): string {
if (!cst) { if (!cst) {
return '!Конституента отсутствует!'; return '!Конституента отсутствует!';
} }
@ -57,14 +58,14 @@ export function describeConstituentaTerm(cst: IConstituenta | null): string {
/** /**
* Generates label for {@link IConstituenta}. * Generates label for {@link IConstituenta}.
*/ */
export function labelConstituenta(cst: IConstituenta) { export function labelConstituenta(cst: RO<IConstituenta>) {
return `${cst.alias}: ${describeConstituenta(cst)}`; return `${cst.alias}: ${describeConstituenta(cst)}`;
} }
/** /**
* Generates label for {@link IVersionInfo} of {@link IRSForm}. * Generates label for {@link IVersionInfo} of {@link IRSForm}.
*/ */
export function labelVersion(value: CurrentVersion, items: IVersionInfo[]) { export function labelVersion(value: CurrentVersion, items: RO<IVersionInfo[]>) {
const version = items.find(ver => ver.id === value); const version = items.find(ver => ver.id === value);
return version ? version.version : 'актуальная'; return version ? version.version : 'актуальная';
} }
@ -345,7 +346,7 @@ export function labelTypification({
}: { }: {
isValid: boolean; isValid: boolean;
resultType: string; resultType: string;
args: IArgumentInfo[]; args: RO<IArgumentInfo[]>;
}): string { }): string {
if (!isValid) { if (!isValid) {
return 'N/A'; return 'N/A';
@ -363,7 +364,7 @@ export function labelTypification({
/** /**
* Generates label for {@link IConstituenta} typification. * Generates label for {@link IConstituenta} typification.
*/ */
export function labelCstTypification(cst: IConstituenta): string { export function labelCstTypification(cst: RO<IConstituenta>): string {
return labelTypification({ return labelTypification({
isValid: cst.parse.status === ParsingStatus.VERIFIED, isValid: cst.parse.status === ParsingStatus.VERIFIED,
resultType: cst.parse.typification, resultType: cst.parse.typification,
@ -374,7 +375,7 @@ export function labelCstTypification(cst: IConstituenta): string {
/** /**
* Generates label for {@link ISyntaxTreeNode}. * Generates label for {@link ISyntaxTreeNode}.
*/ */
export function labelSyntaxTree(node: ISyntaxTreeNode): string { export function labelSyntaxTree(node: RO<ISyntaxTreeNode>): string {
// prettier-ignore // prettier-ignore
switch (node.typeID) { switch (node.typeID) {
case TokenID.ID_LOCAL: case TokenID.ID_LOCAL:
@ -531,7 +532,7 @@ export function labelGrammeme(gram: Grammeme): string {
/** /**
* Generates error description for {@link IRSErrorDescription}. * Generates error description for {@link IRSErrorDescription}.
*/ */
export function describeRSError(error: IRSErrorDescription): string { export function describeRSError(error: RO<IRSErrorDescription>): string {
// prettier-ignore // prettier-ignore
switch (error.errorType) { switch (error.errorType) {
case RSErrorType.unknownSymbol: case RSErrorType.unknownSymbol:

View File

@ -4,6 +4,7 @@
import { BASIC_SCHEMAS, type ILibraryItem } from '@/features/library'; import { BASIC_SCHEMAS, type ILibraryItem } from '@/features/library';
import { type RO } from '@/utils/meta';
import { TextMatcher } from '@/utils/utils'; import { TextMatcher } from '@/utils/utils';
import { CstType, ParsingStatus, ValueClass } from '../backend/types'; import { CstType, ParsingStatus, ValueClass } from '../backend/types';
@ -18,7 +19,7 @@ import { CATEGORY_CST_TYPE, CstClass, ExpressionStatus, type IConstituenta, type
* @param query - The query string used for matching. * @param query - The query string used for matching.
* @param mode - The matching mode to determine which properties to include in the matching process. * @param mode - The matching mode to determine which properties to include in the matching process.
*/ */
export function matchConstituenta(target: IConstituenta, query: string, mode: CstMatchMode): boolean { export function matchConstituenta(target: RO<IConstituenta>, query: string, mode: CstMatchMode): boolean {
const matcher = new TextMatcher(query); const matcher = new TextMatcher(query);
if ((mode === CstMatchMode.ALL || mode === CstMatchMode.NAME) && matcher.test(target.alias)) { if ((mode === CstMatchMode.ALL || mode === CstMatchMode.NAME) && matcher.test(target.alias)) {
return true; return true;
@ -214,7 +215,7 @@ export function isFunctional(type: CstType): boolean {
/** /**
* Evaluate if {@link IConstituenta} can be used produce structure. * Evaluate if {@link IConstituenta} can be used produce structure.
*/ */
export function canProduceStructure(cst: IConstituenta): boolean { export function canProduceStructure(cst: RO<IConstituenta>): boolean {
return !!cst.parse.typification && cst.cst_type !== CstType.BASE && cst.cst_type !== CstType.CONSTANT; return !!cst.parse.typification && cst.cst_type !== CstType.BASE && cst.cst_type !== CstType.CONSTANT;
} }
@ -271,7 +272,7 @@ export function generateAlias(type: CstType, schema: IRSForm, takenAliases: stri
/** /**
* Sorts library items relevant for InlineSynthesis with specified {@link IRSForm}. * Sorts library items relevant for InlineSynthesis with specified {@link IRSForm}.
*/ */
export function sortItemsForInlineSynthesis(receiver: IRSForm, items: ILibraryItem[]): ILibraryItem[] { export function sortItemsForInlineSynthesis(receiver: IRSForm, items: readonly ILibraryItem[]): ILibraryItem[] {
const result = items.filter(item => item.location === receiver.location); const result = items.filter(item => item.location === receiver.location);
for (const item of items) { for (const item of items) {
if (item.visible && item.owner === item.owner && !result.includes(item)) { if (item.visible && item.owner === item.owner && !result.includes(item)) {

View File

@ -6,6 +6,7 @@ import { type Tree } from '@lezer/common';
import { cursorNode } from '@/utils/codemirror'; import { cursorNode } from '@/utils/codemirror';
import { PARAMETER } from '@/utils/constants'; import { PARAMETER } from '@/utils/constants';
import { type RO } from '@/utils/meta';
import { CstType, type IRSErrorDescription, type RSErrorType } from '../backend/types'; import { CstType, type IRSErrorDescription, type RSErrorType } from '../backend/types';
import { labelCstTypification } from '../labels'; import { labelCstTypification } from '../labels';
@ -36,7 +37,7 @@ export function isSetTypification(text: string): boolean {
} }
/** Infers type of constituent for a given template and arguments. */ /** Infers type of constituent for a given template and arguments. */
export function inferTemplatedType(templateType: CstType, args: IArgumentValue[]): CstType { export function inferTemplatedType(templateType: CstType, args: RO<IArgumentValue[]>): CstType {
if (args.length === 0 || args.some(arg => !arg.value)) { if (args.length === 0 || args.some(arg => !arg.value)) {
return templateType; return templateType;
} else if (templateType === CstType.PREDICATE) { } else if (templateType === CstType.PREDICATE) {
@ -92,7 +93,7 @@ export function splitTemplateDefinition(target: string) {
* It replaces template argument placeholders in the expression with their corresponding values * It replaces template argument placeholders in the expression with their corresponding values
* from the provided arguments. * from the provided arguments.
*/ */
export function substituteTemplateArgs(expression: string, args: IArgumentValue[]): string { export function substituteTemplateArgs(expression: string, args: RO<IArgumentValue[]>): string {
if (args.every(arg => !arg.value)) { if (args.every(arg => !arg.value)) {
return expression; return expression;
} }
@ -121,7 +122,7 @@ export function substituteTemplateArgs(expression: string, args: IArgumentValue[
/** /**
* Generate ErrorID label. * Generate ErrorID label.
*/ */
export function getRSErrorPrefix(error: IRSErrorDescription): string { export function getRSErrorPrefix(error: RO<IRSErrorDescription>): string {
const id = error.errorType.toString(16); const id = error.errorType.toString(16);
// prettier-ignore // prettier-ignore
switch(inferErrorClass(error.errorType)) { switch(inferErrorClass(error.errorType)) {
@ -133,12 +134,12 @@ export function getRSErrorPrefix(error: IRSErrorDescription): string {
} }
/** Apply alias mapping. */ /** Apply alias mapping. */
export function applyAliasMapping(target: string, mapping: AliasMapping): string { export function applyAliasMapping(target: string, mapping: RO<AliasMapping>): string {
return applyPattern(target, mapping, GLOBALS_REGEXP); return applyPattern(target, mapping, GLOBALS_REGEXP);
} }
/** Apply alias typification mapping. */ /** Apply alias typification mapping. */
export function applyTypificationMapping(target: string, mapping: AliasMapping): string { export function applyTypificationMapping(target: string, mapping: RO<AliasMapping>): string {
const modified = applyAliasMapping(target, mapping); const modified = applyAliasMapping(target, mapping);
if (modified === target) { if (modified === target) {
return target; return target;

View File

@ -3,6 +3,7 @@
*/ */
import { PARAMETER } from '@/utils/constants'; import { PARAMETER } from '@/utils/constants';
import { type RO } from '@/utils/meta';
import { type IArgumentInfo } from './rslang'; import { type IArgumentInfo } from './rslang';
@ -35,7 +36,7 @@ export class TypificationGraph {
* @param result - typification of the formal definition. * @param result - typification of the formal definition.
* @param args - arguments for term or predicate function. * @param args - arguments for term or predicate function.
*/ */
addConstituenta(alias: string, result: string, args: IArgumentInfo[]): void { addConstituenta(alias: string, result: string, args: RO<IArgumentInfo[]>): void {
const argsNode = this.processArguments(args); const argsNode = this.processArguments(args);
const resultNode = this.processResult(result); const resultNode = this.processResult(result);
const combinedNode = this.combineResults(resultNode, argsNode); const combinedNode = this.combineResults(resultNode, argsNode);
@ -122,7 +123,7 @@ export class TypificationGraph {
this.nodeByAlias.set(alias, nodeToAnnotate); this.nodeByAlias.set(alias, nodeToAnnotate);
} }
private processArguments(args: IArgumentInfo[]): TypificationGraphNode | null { private processArguments(args: RO<IArgumentInfo[]>): TypificationGraphNode | null {
if (args.length === 0) { if (args.length === 0) {
return null; return null;
} }

View File

@ -13,6 +13,7 @@ import { Indicator } from '@/components/view';
import { useDialogsStore } from '@/stores/dialogs'; import { useDialogsStore } from '@/stores/dialogs';
import { useModificationStore } from '@/stores/modification'; import { useModificationStore } from '@/stores/modification';
import { errorMsg, tooltipText } from '@/utils/labels'; import { errorMsg, tooltipText } from '@/utils/labels';
import { type RO } from '@/utils/meta';
import { promptUnsaved } from '@/utils/utils'; import { promptUnsaved } from '@/utils/utils';
import { import {
@ -68,7 +69,7 @@ export function FormConstituenta({ disabled, id, toggleReset, schema, activeCst,
} }
}); });
const [forceComment, setForceComment] = useState(false); const [forceComment, setForceComment] = useState(false);
const [localParse, setLocalParse] = useState<IExpressionParseDTO | null>(null); const [localParse, setLocalParse] = useState<RO<IExpressionParseDTO> | null>(null);
const typification = useMemo( const typification = useMemo(
() => () =>

View File

@ -8,6 +8,7 @@ import { useResetOnChange } from '@/hooks/use-reset-on-change';
import { useDialogsStore } from '@/stores/dialogs'; import { useDialogsStore } from '@/stores/dialogs';
import { usePreferencesStore } from '@/stores/preferences'; import { usePreferencesStore } from '@/stores/preferences';
import { errorMsg } from '@/utils/labels'; import { errorMsg } from '@/utils/labels';
import { type RO } from '@/utils/meta';
import { import {
type ICheckConstituentaDTO, type ICheckConstituentaDTO,
@ -42,7 +43,7 @@ interface EditorRSExpressionProps {
disabled?: boolean; disabled?: boolean;
toggleReset?: boolean; toggleReset?: boolean;
onChangeLocalParse: (typification: IExpressionParseDTO) => void; onChangeLocalParse: (typification: RO<IExpressionParseDTO>) => void;
onOpenEdit: (cstID: number) => void; onOpenEdit: (cstID: number) => void;
onShowTypeGraph: (event: React.MouseEvent<Element>) => void; onShowTypeGraph: (event: React.MouseEvent<Element>) => void;
} }
@ -62,7 +63,7 @@ export function EditorRSExpression({
const [isModified, setIsModified] = useState(false); const [isModified, setIsModified] = useState(false);
const rsInput = useRef<ReactCodeMirrorRef>(null); const rsInput = useRef<ReactCodeMirrorRef>(null);
const [parseData, setParseData] = useState<IExpressionParseDTO | null>(null); const [parseData, setParseData] = useState<RO<IExpressionParseDTO> | null>(null);
const isProcessing = useMutatingRSForm(); const isProcessing = useMutatingRSForm();
const showControls = usePreferencesStore(state => state.showExpressionControls); const showControls = usePreferencesStore(state => state.showExpressionControls);
@ -78,7 +79,7 @@ export function EditorRSExpression({
function checkConstituenta( function checkConstituenta(
expression: string, expression: string,
activeCst: IConstituenta, activeCst: IConstituenta,
onSuccess?: (data: IExpressionParseDTO) => void onSuccess?: (data: RO<IExpressionParseDTO>) => void
) { ) {
const data: ICheckConstituentaDTO = { const data: ICheckConstituentaDTO = {
definition_formal: expression, definition_formal: expression,
@ -96,7 +97,7 @@ export function EditorRSExpression({
setIsModified(newValue !== activeCst.definition_formal); setIsModified(newValue !== activeCst.definition_formal);
} }
function handleCheckExpression(callback?: (parse: IExpressionParseDTO) => void) { function handleCheckExpression(callback?: (parse: RO<IExpressionParseDTO>) => void) {
checkConstituenta(value, activeCst, parse => { checkConstituenta(value, activeCst, parse => {
onChangeLocalParse(parse); onChangeLocalParse(parse);
if (parse.errors.length > 0) { if (parse.errors.length > 0) {
@ -109,7 +110,7 @@ export function EditorRSExpression({
}); });
} }
function onShowError(error: IRSErrorDescription, prefixLen: number) { function onShowError(error: RO<IRSErrorDescription>, prefixLen: number) {
if (!rsInput.current) { if (!rsInput.current) {
return; return;
} }

View File

@ -1,14 +1,16 @@
import clsx from 'clsx'; import clsx from 'clsx';
import { type RO } from '@/utils/meta';
import { type IExpressionParseDTO, type IRSErrorDescription } from '../../../backend/types'; import { type IExpressionParseDTO, type IRSErrorDescription } from '../../../backend/types';
import { describeRSError } from '../../../labels'; import { describeRSError } from '../../../labels';
import { getRSErrorPrefix } from '../../../models/rslang-api'; import { getRSErrorPrefix } from '../../../models/rslang-api';
interface ParsingResultProps { interface ParsingResultProps {
data: IExpressionParseDTO | null; data: RO<IExpressionParseDTO> | null;
disabled?: boolean; disabled?: boolean;
isOpen: boolean; isOpen: boolean;
onShowError: (error: IRSErrorDescription) => void; onShowError: (error: RO<IRSErrorDescription>) => void;
} }
export function ParsingResult({ isOpen, data, disabled, onShowError }: ParsingResultProps) { export function ParsingResult({ isOpen, data, disabled, onShowError }: ParsingResultProps) {

View File

@ -8,6 +8,7 @@ import { BadgeHelp } from '@/features/help/components';
import { Loader } from '@/components/loader'; import { Loader } from '@/components/loader';
import { APP_COLORS } from '@/styling/colors'; import { APP_COLORS } from '@/styling/colors';
import { globalIDs } from '@/utils/constants'; import { globalIDs } from '@/utils/constants';
import { type RO } from '@/utils/meta';
import { prepareTooltip } from '@/utils/utils'; import { prepareTooltip } from '@/utils/utils';
import { type IExpressionParseDTO, ParsingStatus } from '../../../backend/types'; import { type IExpressionParseDTO, ParsingStatus } from '../../../backend/types';
@ -21,7 +22,7 @@ interface StatusBarProps {
className?: string; className?: string;
processing: boolean; processing: boolean;
isModified: boolean; isModified: boolean;
parseData: IExpressionParseDTO | null; parseData: RO<IExpressionParseDTO> | null;
activeCst: IConstituenta; activeCst: IConstituenta;
onAnalyze: () => void; onAnalyze: () => void;
} }

View File

@ -81,7 +81,7 @@ export function MenuEditSchema() {
cstID: targetCst.id cstID: targetCst.id
}).then(cstList => { }).then(cstList => {
if (cstList.length !== 0) { if (cstList.length !== 0) {
setSelected(cstList); setSelected([...cstList]);
} }
}); });
} }

View File

@ -26,6 +26,7 @@ import { useDialogsStore } from '@/stores/dialogs';
import { useModificationStore } from '@/stores/modification'; import { useModificationStore } from '@/stores/modification';
import { EXTEOR_TRS_FILE } from '@/utils/constants'; import { EXTEOR_TRS_FILE } from '@/utils/constants';
import { infoMsg, tooltipText } from '@/utils/labels'; import { infoMsg, tooltipText } from '@/utils/labels';
import { type RO } from '@/utils/meta';
import { generatePageQR, promptUnsaved, sharePage } from '@/utils/utils'; import { generatePageQR, promptUnsaved, sharePage } from '@/utils/utils';
import { useDownloadRSForm } from '../../backend/use-download-rsform'; import { useDownloadRSForm } from '../../backend/use-download-rsform';
@ -78,9 +79,9 @@ export function MenuMain() {
void download({ void download({
itemID: schema.id, itemID: schema.id,
version: schema.version === 'latest' ? undefined : schema.version version: schema.version === 'latest' ? undefined : schema.version
}).then((data: Blob) => { }).then((data: RO<Blob>) => {
try { try {
fileDownload(data, fileName); fileDownload(data as Blob, fileName);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
} }

View File

@ -14,6 +14,7 @@ import { useModificationStore } from '@/stores/modification';
import { usePreferencesStore } from '@/stores/preferences'; import { usePreferencesStore } from '@/stores/preferences';
import { PARAMETER, prefixes } from '@/utils/constants'; import { PARAMETER, prefixes } from '@/utils/constants';
import { promptText } from '@/utils/labels'; import { promptText } from '@/utils/labels';
import { type RO } from '@/utils/meta';
import { promptUnsaved } from '@/utils/utils'; import { promptUnsaved } from '@/utils/utils';
import { CstType, type IConstituentaBasicsDTO, type ICreateConstituentaDTO } from '../../backend/types'; import { CstType, type IConstituentaBasicsDTO, type ICreateConstituentaDTO } from '../../backend/types';
@ -136,7 +137,7 @@ export const RSEditState = ({
}); });
} }
function onCreateCst(newCst: IConstituentaBasicsDTO) { function onCreateCst(newCst: RO<IConstituentaBasicsDTO>) {
setSelected([newCst.id]); setSelected([newCst.id]);
navigateRSForm({ tab: activeTab, activeID: newCst.id }); navigateRSForm({ tab: activeTab, activeID: newCst.id });
if (activeTab === RSTabID.CST_LIST) { if (activeTab === RSTabID.CST_LIST) {

View File

@ -0,0 +1,83 @@
/**
* Module: Generic high order utility functions.
*/
// List of disallowed object types (non-plain)
// ALLOW: Date | RegExp | ArrayBuffer | DataView
type DisallowedObjects =
| Map<unknown, unknown>
| Set<unknown>
| WeakMap<object, unknown>
| WeakSet<object>
| Promise<unknown>;
// Detects any disallowed object type directly
type IsDisallowedObject<T> = T extends DisallowedObjects ? true : false;
// Detects if any property in T is a function
type HasFunctionProps<T> = {
[K in keyof T]: T[K] extends (...args: unknown[]) => unknown ? true : false;
}[keyof T] extends true
? true
: false;
// Detects if any property in T is a disallowed object
type HasDisallowedProps<T> = {
[K in keyof T]: IsDisallowedObject<T[K]>;
}[keyof T] extends true
? true
: false;
// Detects if T is a class instance (has constructor)
type IsClassInstance<T> = T extends object
? T extends { constructor: new (...args: unknown[]) => unknown }
? true
: false
: false;
// The final check — should the object be rejected?
type IsInvalid<T> = T extends object
? HasFunctionProps<T> extends true
? true
: HasDisallowedProps<T> extends true
? true
: IsClassInstance<T> extends true
? true
: false
: false;
/**
* Apply readonly modifier to all properties of a SIMPLE object recursively.
* only works for arrays, dictionaries and primitives.
*/
export type RO<T> = IsInvalid<T> extends true
? never
: T extends readonly unknown[]
? readonly RO<T[number]>[]
: T extends object
? { readonly [K in keyof T]: RO<T[K]> }
: T;
/**
* Freeze an object.
*/
export function deepFreeze<T>(obj: T): RO<T> {
// Ensure the input object is not null or undefined
if (obj === null || obj === undefined) {
throw new Error('Cannot freeze null or undefined');
}
// Freeze the current object
Object.freeze(obj);
// Freeze properties recursively
Object.getOwnPropertyNames(obj).forEach(prop => {
const value = (obj as Record<string, unknown>)[prop];
if (value !== null && typeof value === 'object' && !Object.isFrozen(value)) {
deepFreeze(value);
}
});
// Return the frozen object
return obj as RO<T>;
}

View File

@ -128,7 +128,7 @@ export function extractErrorMessage(error: Error | AxiosError): string {
/** /**
* Convert array of objects to CSV Blob. * Convert array of objects to CSV Blob.
*/ */
export function convertToCSV(targetObj: object[]): Blob { export function convertToCSV(targetObj: readonly object[]): Blob {
if (!targetObj || targetObj.length === 0) { if (!targetObj || targetObj.length === 0) {
return new Blob([], { type: 'text/csv;charset=utf-8;' }); return new Blob([], { type: 'text/csv;charset=utf-8;' });
} }