mirror of
https://github.com/portainer/portainer.git
synced 2025-07-24 07:49:41 +02:00
refactor(docker/stacks): migrate table to react [EE-4705] (#9956)
This commit is contained in:
parent
c3d266931f
commit
c8a1f0fa77
43 changed files with 1127 additions and 492 deletions
|
@ -0,0 +1,113 @@
|
|||
import { Layers } from 'lucide-react';
|
||||
import { Row } from '@tanstack/react-table';
|
||||
|
||||
import { useAuthorizations, useCurrentUser } from '@/react/hooks/useUser';
|
||||
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
|
||||
|
||||
import { Datatable } from '@@/datatables';
|
||||
import { useTableState } from '@@/datatables/useTableState';
|
||||
import { useRepeater } from '@@/datatables/useRepeater';
|
||||
import { defaultGlobalFilterFn } from '@@/datatables/Datatable';
|
||||
import { withGlobalFilter } from '@@/datatables/extend-options/withGlobalFilter';
|
||||
|
||||
import { isExternalStack, isOrphanedStack } from '../../view-models/utils';
|
||||
|
||||
import { TableActions } from './TableActions';
|
||||
import { TableSettingsMenus } from './TableSettingsMenus';
|
||||
import { createStore } from './store';
|
||||
import { useColumns } from './columns';
|
||||
import { DecoratedStack } from './types';
|
||||
|
||||
const tableKey = 'docker_stacks';
|
||||
const settingsStore = createStore(tableKey);
|
||||
|
||||
export function StacksDatatable({
|
||||
onRemove,
|
||||
onReload,
|
||||
isImageNotificationEnabled,
|
||||
dataset,
|
||||
}: {
|
||||
onRemove: (items: Array<DecoratedStack>) => void;
|
||||
onReload: () => void;
|
||||
isImageNotificationEnabled: boolean;
|
||||
dataset: Array<DecoratedStack>;
|
||||
}) {
|
||||
const tableState = useTableState(settingsStore, tableKey);
|
||||
useRepeater(tableState.autoRefreshRate, onReload);
|
||||
const { isAdmin } = useCurrentUser();
|
||||
const canManageStacks = useAuthorizations([
|
||||
'PortainerStackCreate',
|
||||
'PortainerStackDelete',
|
||||
]);
|
||||
const columns = useColumns(isImageNotificationEnabled);
|
||||
|
||||
return (
|
||||
<Datatable<DecoratedStack>
|
||||
settingsManager={tableState}
|
||||
title="Stacks"
|
||||
titleIcon={Layers}
|
||||
renderTableActions={(selectedRows) => (
|
||||
<TableActions selectedItems={selectedRows} onRemove={onRemove} />
|
||||
)}
|
||||
renderTableSettings={(tableInstance) => (
|
||||
<TableSettingsMenus
|
||||
tableInstance={tableInstance}
|
||||
tableState={tableState}
|
||||
/>
|
||||
)}
|
||||
columns={columns}
|
||||
dataset={dataset}
|
||||
isRowSelectable={({ original: item }) =>
|
||||
allowSelection(item, isAdmin, canManageStacks)
|
||||
}
|
||||
getRowId={(item) => item.Id.toString()}
|
||||
initialTableState={{
|
||||
globalFilter: {
|
||||
showOrphanedStacks: tableState.showOrphanedStacks,
|
||||
},
|
||||
columnVisibility: Object.fromEntries(
|
||||
tableState.hiddenColumns.map((col) => [col, false])
|
||||
),
|
||||
}}
|
||||
extendTableOptions={withGlobalFilter(globalFilterFn)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function allowSelection(
|
||||
item: DecoratedStack,
|
||||
isAdmin: boolean,
|
||||
canManageStacks: boolean
|
||||
) {
|
||||
if (isExternalStack(item)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isBE && isOrphanedStack(item) && !isAdmin) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isAdmin || canManageStacks;
|
||||
}
|
||||
|
||||
function globalFilterFn(
|
||||
row: Row<DecoratedStack>,
|
||||
columnId: string,
|
||||
filterValue: null | { showOrphanedStacks: boolean; search: string }
|
||||
) {
|
||||
return (
|
||||
orphanedFilter(row, filterValue) &&
|
||||
defaultGlobalFilterFn(row, columnId, filterValue)
|
||||
);
|
||||
}
|
||||
|
||||
function orphanedFilter(
|
||||
row: Row<DecoratedStack>,
|
||||
filterValue: null | { showOrphanedStacks: boolean; search: string }
|
||||
) {
|
||||
if (filterValue?.showOrphanedStacks) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return !isOrphanedStack(row.original);
|
||||
}
|
|
@ -0,0 +1,45 @@
|
|||
import { Trash2, Plus } from 'lucide-react';
|
||||
|
||||
import { Authorized } from '@/react/hooks/useUser';
|
||||
|
||||
import { Link } from '@@/Link';
|
||||
import { Button } from '@@/buttons';
|
||||
|
||||
import { DecoratedStack } from './types';
|
||||
|
||||
export function TableActions({
|
||||
selectedItems,
|
||||
onRemove,
|
||||
}: {
|
||||
selectedItems: Array<DecoratedStack>;
|
||||
onRemove: (items: Array<DecoratedStack>) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Authorized authorizations="PortainerStackDelete">
|
||||
<Button
|
||||
color="dangerlight"
|
||||
disabled={selectedItems.length === 0}
|
||||
onClick={() => onRemove(selectedItems)}
|
||||
icon={Trash2}
|
||||
className="!m-0"
|
||||
data-cy="stack-removeStackButton"
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</Authorized>
|
||||
|
||||
<Authorized authorizations="PortainerStackCreate">
|
||||
<Button
|
||||
as={Link}
|
||||
props={{ to: '.newstack' }}
|
||||
icon={Plus}
|
||||
className="!m-0"
|
||||
data-cy="stack-addStackButton"
|
||||
>
|
||||
Add stack
|
||||
</Button>
|
||||
</Authorized>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,61 @@
|
|||
import { Table } from '@tanstack/react-table';
|
||||
|
||||
import { Authorized } from '@/react/hooks/useUser';
|
||||
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
|
||||
|
||||
import { ColumnVisibilityMenu } from '@@/datatables/ColumnVisibilityMenu';
|
||||
import { TableSettingsMenu } from '@@/datatables';
|
||||
import { TableSettingsMenuAutoRefresh } from '@@/datatables/TableSettingsMenuAutoRefresh';
|
||||
import { Checkbox } from '@@/form-components/Checkbox';
|
||||
|
||||
import { TableSettings } from './store';
|
||||
import { DecoratedStack } from './types';
|
||||
|
||||
export function TableSettingsMenus({
|
||||
tableInstance,
|
||||
tableState,
|
||||
}: {
|
||||
tableInstance: Table<DecoratedStack>;
|
||||
tableState: TableSettings;
|
||||
}) {
|
||||
const columnsToHide = tableInstance
|
||||
.getAllColumns()
|
||||
.filter((col) => col.getCanHide());
|
||||
|
||||
return (
|
||||
<>
|
||||
<ColumnVisibilityMenu<DecoratedStack>
|
||||
columns={columnsToHide}
|
||||
onChange={(hiddenColumns) => {
|
||||
tableState.setHiddenColumns(hiddenColumns);
|
||||
tableInstance.setColumnVisibility(
|
||||
Object.fromEntries(hiddenColumns.map((col) => [col, false]))
|
||||
);
|
||||
}}
|
||||
value={tableState.hiddenColumns}
|
||||
/>
|
||||
<TableSettingsMenu>
|
||||
{isBE && (
|
||||
<Authorized authorizations="EndpointResourcesAccess">
|
||||
<Checkbox
|
||||
id="setting_all_orphaned_stacks"
|
||||
label="Show all orphaned stacks"
|
||||
checked={tableState.showOrphanedStacks}
|
||||
onChange={(e) => {
|
||||
tableState.setShowOrphanedStacks(e.target.checked);
|
||||
tableInstance.setGlobalFilter((filter: object) => ({
|
||||
...filter,
|
||||
showOrphanedStacks: e.target.checked,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</Authorized>
|
||||
)}
|
||||
<TableSettingsMenuAutoRefresh
|
||||
value={tableState.autoRefreshRate}
|
||||
onChange={(value) => tableState.setAutoRefreshRate(value)}
|
||||
/>
|
||||
</TableSettingsMenu>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,56 @@
|
|||
import { useQuery } from 'react-query';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import { useEnvironment } from '@/react/portainer/environments/queries';
|
||||
import { statusIcon } from '@/react/docker/components/ImageStatus/helpers';
|
||||
|
||||
import { Icon } from '@@/Icon';
|
||||
|
||||
import { getStackImagesStatus } from './getStackImagesStatus';
|
||||
|
||||
export interface Props {
|
||||
stackId: number;
|
||||
environmentId: number;
|
||||
}
|
||||
|
||||
export function StackImageStatus({ stackId, environmentId }: Props) {
|
||||
const { data, isLoading, isError } = useStackImageNotification(
|
||||
stackId,
|
||||
environmentId
|
||||
);
|
||||
|
||||
if (isError) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isLoading || !data) {
|
||||
return (
|
||||
<Icon
|
||||
icon={Loader2}
|
||||
size="sm"
|
||||
className="!mr-1 animate-spin-slow align-middle"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <Icon icon={statusIcon(data)} className="!mr-1 align-middle" />;
|
||||
}
|
||||
|
||||
export function useStackImageNotification(
|
||||
stackId: number,
|
||||
environmentId?: EnvironmentId
|
||||
) {
|
||||
const enableImageNotificationQuery = useEnvironment(
|
||||
environmentId,
|
||||
(environment) => environment?.EnableImageNotification
|
||||
);
|
||||
|
||||
return useQuery(
|
||||
['stacks', stackId, 'images', 'status'],
|
||||
() => getStackImagesStatus(stackId),
|
||||
{
|
||||
enabled: enableImageNotificationQuery.data,
|
||||
}
|
||||
);
|
||||
}
|
|
@ -0,0 +1,63 @@
|
|||
import { CellContext } from '@tanstack/react-table';
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
import { PropsWithChildren } from 'react';
|
||||
|
||||
import {
|
||||
isExternalStack,
|
||||
isOrphanedStack,
|
||||
isRegularStack,
|
||||
} from '@/react/docker/stacks/view-models/utils';
|
||||
|
||||
import { TooltipWithChildren } from '@@/Tip/TooltipWithChildren';
|
||||
import { Icon } from '@@/Icon';
|
||||
|
||||
import { DecoratedStack } from '../types';
|
||||
|
||||
import { columnHelper } from './helper';
|
||||
|
||||
export const control = columnHelper.display({
|
||||
header: 'Control',
|
||||
id: 'control',
|
||||
cell: ControlCell,
|
||||
enableHiding: false,
|
||||
});
|
||||
|
||||
function ControlCell({
|
||||
row: { original: item },
|
||||
}: CellContext<DecoratedStack, unknown>) {
|
||||
if (isRegularStack(item)) {
|
||||
return <>Total</>;
|
||||
}
|
||||
|
||||
if (isExternalStack(item)) {
|
||||
return (
|
||||
<Warning tooltip="This stack was created outside of Portainer. Control over this stack is limited.">
|
||||
Limited
|
||||
</Warning>
|
||||
);
|
||||
}
|
||||
|
||||
if (isOrphanedStack(item)) {
|
||||
return (
|
||||
<Warning tooltip="This stack was created inside an environment that is no longer registered inside Portainer.">
|
||||
Orphaned
|
||||
</Warning>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function Warning({
|
||||
tooltip,
|
||||
children,
|
||||
}: PropsWithChildren<{ tooltip: string }>) {
|
||||
return (
|
||||
<TooltipWithChildren message={tooltip}>
|
||||
<span className="flex items-center gap-2">
|
||||
{children}
|
||||
<Icon icon={AlertCircle} mode="warning" />
|
||||
</span>
|
||||
</TooltipWithChildren>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,41 @@
|
|||
import { isExternalStack } from '@/react/docker/stacks/view-models/utils';
|
||||
|
||||
import { columnHelper } from './helper';
|
||||
|
||||
export const deployedVersion = columnHelper.accessor(
|
||||
(item) => {
|
||||
if (isExternalStack(item)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return item.GitConfig ? item.GitConfig.ConfigHash : item.StackFileVersion;
|
||||
},
|
||||
{
|
||||
header: 'Deployed Version',
|
||||
id: 'deployed-version',
|
||||
cell: ({ row: { original: item } }) => {
|
||||
if (isExternalStack(item)) {
|
||||
return <div className="text-center">-</div>;
|
||||
}
|
||||
|
||||
if (item.GitConfig) {
|
||||
return (
|
||||
<div className="text-center">
|
||||
<a
|
||||
target="_blank"
|
||||
href={`${item.GitConfig.URL}/commit/${item.GitConfig.ConfigHash}`}
|
||||
rel="noreferrer"
|
||||
>
|
||||
{item.GitConfig.ConfigHash.slice(0, 7)}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <div className="text-center">{item.StackFileVersion || '-'}</div>;
|
||||
},
|
||||
meta: {
|
||||
className: '[&>*]:justify-center',
|
||||
},
|
||||
}
|
||||
);
|
|
@ -0,0 +1,16 @@
|
|||
import axios from '@/portainer/services/axios';
|
||||
import { ImageStatus } from '@/react/docker/images/types';
|
||||
|
||||
export async function getStackImagesStatus(id: number) {
|
||||
try {
|
||||
const { data } = await axios.get<ImageStatus>(
|
||||
`/stacks/${id}/images_status`
|
||||
);
|
||||
return data;
|
||||
} catch (e) {
|
||||
return {
|
||||
Status: 'unknown',
|
||||
Message: `Unable to retrieve image status for stack: ${id}`,
|
||||
};
|
||||
}
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
import { createColumnHelper } from '@tanstack/react-table';
|
||||
|
||||
import { DecoratedStack } from '../types';
|
||||
|
||||
export const columnHelper = createColumnHelper<DecoratedStack>();
|
|
@ -0,0 +1,34 @@
|
|||
import { CellContext } from '@tanstack/react-table';
|
||||
|
||||
import { ImageUpToDateTooltip } from '@/react/docker/components/datatable/TableColumnHeaderImageUpToDate';
|
||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
import { isRegularStack } from '@/react/docker/stacks/view-models/utils';
|
||||
|
||||
import { DecoratedStack } from '../types';
|
||||
|
||||
import { StackImageStatus } from './StackImageStatus';
|
||||
import { columnHelper } from './helper';
|
||||
|
||||
export const imageNotificationColumn = columnHelper.display({
|
||||
id: 'imageNotification',
|
||||
enableHiding: false,
|
||||
header: () => (
|
||||
<>
|
||||
Images up to date
|
||||
<ImageUpToDateTooltip />
|
||||
</>
|
||||
),
|
||||
cell: Cell,
|
||||
});
|
||||
|
||||
function Cell({
|
||||
row: { original: item },
|
||||
}: CellContext<DecoratedStack, unknown>) {
|
||||
const environmentId = useEnvironmentId();
|
||||
|
||||
if (!isRegularStack(item)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <StackImageStatus environmentId={environmentId} stackId={item.Id} />;
|
||||
}
|
|
@ -0,0 +1,58 @@
|
|||
import _ from 'lodash';
|
||||
|
||||
import { StackType } from '@/react/common/stacks/types';
|
||||
import { isoDateFromTimestamp } from '@/portainer/filters/filters';
|
||||
import { createOwnershipColumn } from '@/react/docker/components/datatable/createOwnershipColumn';
|
||||
|
||||
import { DecoratedStack } from '../types';
|
||||
|
||||
import { columnHelper } from './helper';
|
||||
import { name } from './name';
|
||||
import { imageNotificationColumn } from './image-notification';
|
||||
import { control } from './control';
|
||||
import { deployedVersion } from './deployed-version';
|
||||
|
||||
export function useColumns(isImageNotificationEnabled: boolean) {
|
||||
return _.compact([
|
||||
name,
|
||||
columnHelper.accessor(
|
||||
(item) => (item.Type === StackType.DockerCompose ? 'Compose' : 'Swarm'),
|
||||
{
|
||||
id: 'type',
|
||||
header: 'Type',
|
||||
enableHiding: false,
|
||||
}
|
||||
),
|
||||
isImageNotificationEnabled && imageNotificationColumn,
|
||||
control,
|
||||
columnHelper.accessor('CreationDate', {
|
||||
id: 'creationDate',
|
||||
header: 'Created',
|
||||
enableHiding: false,
|
||||
cell: ({ getValue, row: { original: item } }) => {
|
||||
const value = getValue();
|
||||
if (!value) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
const by = item.CreatedBy ? `by ${item.CreatedBy}` : '';
|
||||
return `${isoDateFromTimestamp(value)} ${by}`.trim();
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor('UpdateDate', {
|
||||
id: 'updateDate',
|
||||
header: 'Updated',
|
||||
cell: ({ getValue, row: { original: item } }) => {
|
||||
const value = getValue();
|
||||
if (!value) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
const by = item.UpdatedBy ? `by ${item.UpdatedBy}` : '';
|
||||
return `${isoDateFromTimestamp(value)} ${by}`.trim();
|
||||
},
|
||||
}),
|
||||
deployedVersion,
|
||||
createOwnershipColumn<DecoratedStack>(false),
|
||||
]);
|
||||
}
|
|
@ -0,0 +1,130 @@
|
|||
import { CellContext, Column } from '@tanstack/react-table';
|
||||
|
||||
import { useCurrentUser } from '@/react/hooks/useUser';
|
||||
import { getValueAsArrayOfStrings } from '@/portainer/helpers/array';
|
||||
import { StackStatus } from '@/react/common/stacks/types';
|
||||
import {
|
||||
isExternalStack,
|
||||
isOrphanedStack,
|
||||
isRegularStack,
|
||||
} from '@/react/docker/stacks/view-models/utils';
|
||||
|
||||
import { Link } from '@@/Link';
|
||||
import { MultipleSelectionFilter } from '@@/datatables/Filter';
|
||||
|
||||
import { DecoratedStack } from '../types';
|
||||
|
||||
import { columnHelper } from './helper';
|
||||
|
||||
const filterOptions = ['Active Stacks', 'Inactive Stacks'] as const;
|
||||
|
||||
type FilterOption = (typeof filterOptions)[number];
|
||||
|
||||
export const name = columnHelper.accessor('Name', {
|
||||
header: 'Name',
|
||||
id: 'name',
|
||||
cell: NameCell,
|
||||
enableHiding: false,
|
||||
enableColumnFilter: true,
|
||||
filterFn: (
|
||||
{ original: stack },
|
||||
columnId,
|
||||
filterValue: Array<FilterOption>
|
||||
) => {
|
||||
if (filterValue.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isExternalStack(stack) || !stack.Status) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
(stack.Status === StackStatus.Active &&
|
||||
filterValue.includes('Active Stacks')) ||
|
||||
(stack.Status === StackStatus.Inactive &&
|
||||
filterValue.includes('Inactive Stacks'))
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
filter: Filter,
|
||||
},
|
||||
});
|
||||
|
||||
function NameCell({
|
||||
row: { original: item },
|
||||
}: CellContext<DecoratedStack, string>) {
|
||||
return (
|
||||
<>
|
||||
<NameLink item={item} />
|
||||
{isRegularStack(item) && item.Status === 2 && (
|
||||
<span className="label label-warning image-tag space-left ml-2">
|
||||
Inactive
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function NameLink({ item }: { item: DecoratedStack }) {
|
||||
const { isAdmin } = useCurrentUser();
|
||||
|
||||
const name = item.Name;
|
||||
|
||||
if (isExternalStack(item)) {
|
||||
return (
|
||||
<Link
|
||||
to="docker.stacks.stack"
|
||||
params={{
|
||||
name: item.Name,
|
||||
type: item.Type,
|
||||
external: true,
|
||||
}}
|
||||
title={name}
|
||||
>
|
||||
{name}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAdmin && isOrphanedStack(item)) {
|
||||
return <>{name}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
to="docker.stacks.stack"
|
||||
params={{
|
||||
name: item.Name,
|
||||
id: item.Id,
|
||||
type: item.Type,
|
||||
regular: item.Regular,
|
||||
orphaned: item.Orphaned,
|
||||
orphanedRunning: item.OrphanedRunning,
|
||||
}}
|
||||
title={name}
|
||||
>
|
||||
{name}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function Filter<TData extends { Used: boolean }>({
|
||||
column: { getFilterValue, setFilterValue, id },
|
||||
}: {
|
||||
column: Column<TData>;
|
||||
}) {
|
||||
const value = getFilterValue();
|
||||
|
||||
const valueAsArray = getValueAsArrayOfStrings(value);
|
||||
|
||||
return (
|
||||
<MultipleSelectionFilter
|
||||
options={filterOptions}
|
||||
filterKey={id}
|
||||
value={valueAsArray}
|
||||
onChange={setFilterValue}
|
||||
menuTitle="Filter by activity"
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { StacksDatatable } from './StacksDatatable';
|
27
app/react/docker/stacks/ListView/StacksDatatable/store.ts
Normal file
27
app/react/docker/stacks/ListView/StacksDatatable/store.ts
Normal file
|
@ -0,0 +1,27 @@
|
|||
import {
|
||||
BasicTableSettings,
|
||||
RefreshableTableSettings,
|
||||
SettableColumnsTableSettings,
|
||||
createPersistedStore,
|
||||
hiddenColumnsSettings,
|
||||
refreshableSettings,
|
||||
} from '@@/datatables/types';
|
||||
|
||||
export interface TableSettings
|
||||
extends BasicTableSettings,
|
||||
SettableColumnsTableSettings,
|
||||
RefreshableTableSettings {
|
||||
showOrphanedStacks: boolean;
|
||||
setShowOrphanedStacks(value: boolean): void;
|
||||
}
|
||||
|
||||
export function createStore(storageKey: string) {
|
||||
return createPersistedStore<TableSettings>(storageKey, 'name', (set) => ({
|
||||
...hiddenColumnsSettings(set),
|
||||
...refreshableSettings(set),
|
||||
showOrphanedStacks: false,
|
||||
setShowOrphanedStacks(showOrphanedStacks) {
|
||||
set((s) => ({ ...s, showOrphanedStacks }));
|
||||
},
|
||||
}));
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
import { StackViewModel } from '../../view-models/stack';
|
||||
import { ExternalStackViewModel } from '../../view-models/external-stack';
|
||||
|
||||
export type DecoratedStack = StackViewModel | ExternalStackViewModel;
|
Loading…
Add table
Add a link
Reference in a new issue