Replace entire codebase with homarr-labs/homarr
This commit is contained in:
72
packages/api/src/router/board/board-access.ts
Normal file
72
packages/api/src/router/board/board-access.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { constructBoardPermissions } from "@homarr/auth/shared";
|
||||
import type { Database, SQL } from "@homarr/db";
|
||||
import { eq, inArray } from "@homarr/db";
|
||||
import { boardGroupPermissions, boardUserPermissions, groupMembers } from "@homarr/db/schema";
|
||||
import type { BoardPermission } from "@homarr/definitions";
|
||||
|
||||
/**
|
||||
* Throws NOT_FOUND if user is not allowed to perform action on board
|
||||
* @param ctx trpc router context
|
||||
* @param boardWhere where clause for the board
|
||||
* @param permission permission required to perform action on board
|
||||
*/
|
||||
export const throwIfActionForbiddenAsync = async (
|
||||
ctx: { db: Database; session: Session | null },
|
||||
boardWhere: SQL<unknown>,
|
||||
permission: BoardPermission,
|
||||
) => {
|
||||
const { db, session } = ctx;
|
||||
const groupsOfCurrentUser = await db.query.groupMembers.findMany({
|
||||
where: eq(groupMembers.userId, session?.user.id ?? ""),
|
||||
});
|
||||
const board = await db.query.boards.findFirst({
|
||||
where: boardWhere,
|
||||
columns: {
|
||||
id: true,
|
||||
creatorId: true,
|
||||
isPublic: true,
|
||||
},
|
||||
with: {
|
||||
userPermissions: {
|
||||
where: eq(boardUserPermissions.userId, session?.user.id ?? ""),
|
||||
},
|
||||
groupPermissions: {
|
||||
where: inArray(boardGroupPermissions.groupId, groupsOfCurrentUser.map((group) => group.groupId).concat("")),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!board) {
|
||||
notAllowed();
|
||||
}
|
||||
|
||||
const { hasViewAccess, hasChangeAccess, hasFullAccess } = constructBoardPermissions(board, session);
|
||||
|
||||
if (hasFullAccess) {
|
||||
return; // As full access is required and user has full access, allow
|
||||
}
|
||||
|
||||
if (["modify", "view"].includes(permission) && hasChangeAccess) {
|
||||
return; // As change access is required and user has change access, allow
|
||||
}
|
||||
|
||||
if (permission === "view" && hasViewAccess) {
|
||||
return; // As view access is required and user has view access, allow
|
||||
}
|
||||
|
||||
notAllowed();
|
||||
};
|
||||
|
||||
/**
|
||||
* This method returns NOT_FOUND to prevent snooping on board existence
|
||||
* A function is used to use the method without return statement
|
||||
*/
|
||||
function notAllowed(): never {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Board not found",
|
||||
});
|
||||
}
|
||||
186
packages/api/src/router/board/grid-algorithm.ts
Normal file
186
packages/api/src/router/board/grid-algorithm.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
export interface GridAlgorithmItem {
|
||||
id: string;
|
||||
type: "item" | "section";
|
||||
width: number;
|
||||
height: number;
|
||||
xOffset: number;
|
||||
yOffset: number;
|
||||
sectionId: string;
|
||||
}
|
||||
|
||||
interface GridAlgorithmInput {
|
||||
items: GridAlgorithmItem[];
|
||||
width: number;
|
||||
previousWidth: number;
|
||||
sectionId: string;
|
||||
}
|
||||
|
||||
interface GridAlgorithmOutput {
|
||||
height: number;
|
||||
items: GridAlgorithmItem[];
|
||||
}
|
||||
|
||||
export const generateResponsiveGridFor = ({
|
||||
items,
|
||||
previousWidth,
|
||||
width,
|
||||
sectionId,
|
||||
}: GridAlgorithmInput): GridAlgorithmOutput => {
|
||||
const itemsOfCurrentSection = items
|
||||
.filter((item) => item.sectionId === sectionId)
|
||||
.sort((itemA, itemB) =>
|
||||
itemA.yOffset === itemB.yOffset ? itemA.xOffset - itemB.xOffset : itemA.yOffset - itemB.yOffset,
|
||||
);
|
||||
const normalizedItems = normalizeItems(itemsOfCurrentSection, width);
|
||||
|
||||
if (itemsOfCurrentSection.length === 0) {
|
||||
return {
|
||||
height: 0,
|
||||
items: [],
|
||||
};
|
||||
}
|
||||
|
||||
const newItems: GridAlgorithmItem[] = [];
|
||||
|
||||
// Fix height of dynamic sections
|
||||
const dynamicSectionHeightMap = new Map<string, number>();
|
||||
const dynamicSectionsOfCurrentSection = normalizedItems.filter((item) => item.type === "section");
|
||||
for (const dynamicSection of dynamicSectionsOfCurrentSection) {
|
||||
const result = generateResponsiveGridFor({
|
||||
items,
|
||||
previousWidth: dynamicSection.previousWidth,
|
||||
width: dynamicSection.width,
|
||||
sectionId: dynamicSection.id,
|
||||
});
|
||||
newItems.push(...result.items);
|
||||
dynamicSectionHeightMap.set(dynamicSection.id, result.height);
|
||||
}
|
||||
|
||||
// Return same positions for items in the current section
|
||||
if (width >= previousWidth) {
|
||||
return {
|
||||
height: Math.max(...itemsOfCurrentSection.map((item) => item.yOffset + item.height)),
|
||||
items: newItems.concat(normalizedItems),
|
||||
};
|
||||
}
|
||||
|
||||
const occupied2d: boolean[][] = [];
|
||||
|
||||
for (const item of normalizedItems) {
|
||||
const itemWithHeight = {
|
||||
...item,
|
||||
height: item.type === "section" ? Math.max(dynamicSectionHeightMap.get(item.id) ?? 1, item.height) : item.height,
|
||||
};
|
||||
const position = nextFreeSpot(occupied2d, itemWithHeight, width);
|
||||
if (!position) throw new Error("No free spot available");
|
||||
|
||||
addItemToOccupied(occupied2d, itemWithHeight, position, width);
|
||||
newItems.push({
|
||||
...itemWithHeight,
|
||||
xOffset: position.x,
|
||||
yOffset: position.y,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
height: occupied2d.length,
|
||||
items: newItems,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Reduces the width of the items to fit the new column count.
|
||||
* @param items items to normalize
|
||||
* @param columnCount new column count
|
||||
*/
|
||||
const normalizeItems = (items: GridAlgorithmItem[], columnCount: number) => {
|
||||
return items.map((item) => ({ ...item, previousWidth: item.width, width: Math.min(columnCount, item.width) }));
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds the item to the occupied spots.
|
||||
* @param occupied2d array of occupied spots
|
||||
* @param item item to place
|
||||
* @param position position to place the item
|
||||
*/
|
||||
const addItemToOccupied = (
|
||||
occupied2d: boolean[][],
|
||||
item: GridAlgorithmItem,
|
||||
position: { x: number; y: number },
|
||||
columnCount: number,
|
||||
) => {
|
||||
for (let yOffset = 0; yOffset < item.height; yOffset++) {
|
||||
let row = occupied2d[position.y + yOffset];
|
||||
if (!row) {
|
||||
addRow(occupied2d, columnCount);
|
||||
// After adding it, it must exist
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
row = occupied2d[position.y + yOffset]!;
|
||||
}
|
||||
|
||||
for (let xOffset = 0; xOffset < item.width; xOffset++) {
|
||||
row[position.x + xOffset] = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds a new row to the grid.
|
||||
* @param occupied2d array of occupied spots
|
||||
* @param columnCount column count of section
|
||||
*/
|
||||
const addRow = (occupied2d: boolean[][], columnCount: number) => {
|
||||
occupied2d.push(new Array<boolean>(columnCount).fill(false));
|
||||
};
|
||||
|
||||
/**
|
||||
* Searches for the next free spot in the grid.
|
||||
* @param occupied2d array of occupied spots
|
||||
* @param item item to place
|
||||
* @param columnCount column count of section
|
||||
* @returns the position of the next free spot or null if no spot is available
|
||||
*/
|
||||
const nextFreeSpot = (occupied2d: boolean[][], item: GridAlgorithmItem, columnCount: number) => {
|
||||
for (let offsetY = 0; offsetY < 99999; offsetY++) {
|
||||
for (let offsetX = 0; offsetX < columnCount; offsetX++) {
|
||||
if (hasHorizontalSpace(columnCount, item, offsetX) && isFree(occupied2d, item, { x: offsetX, y: offsetY })) {
|
||||
return { x: offsetX, y: offsetY };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if the item fits into the grid horizontally.
|
||||
* @param columnCount available width
|
||||
* @param item item to place
|
||||
* @param offsetX current x position
|
||||
* @returns true if the item fits horizontally
|
||||
*/
|
||||
const hasHorizontalSpace = (columnCount: number, item: GridAlgorithmItem, offsetX: number) => {
|
||||
return offsetX + item.width <= columnCount;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if the spot is free.
|
||||
* @param occupied2d array of occupied spots
|
||||
* @param item item to place
|
||||
* @param position position to check
|
||||
* @returns true if the spot is free
|
||||
*/
|
||||
const isFree = (occupied2d: boolean[][], item: GridAlgorithmItem, position: { x: number; y: number }) => {
|
||||
for (let yOffset = 0; yOffset < item.height; yOffset++) {
|
||||
const row = occupied2d[position.y + yOffset];
|
||||
if (!row) return true; // Empty row is free
|
||||
|
||||
for (let xOffset = 0; xOffset < item.width; xOffset++) {
|
||||
if (row[position.x + xOffset]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
378
packages/api/src/router/board/test/grid-algorithm.spec.ts
Normal file
378
packages/api/src/router/board/test/grid-algorithm.spec.ts
Normal file
@@ -0,0 +1,378 @@
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { GridAlgorithmItem } from "../grid-algorithm";
|
||||
import { generateResponsiveGridFor } from "../grid-algorithm";
|
||||
|
||||
const ROOT_SECTION_ID = "section";
|
||||
|
||||
/**
|
||||
* If you want to see how the layouts progress between the different layouts, you can find images here:
|
||||
* https://github.com/homarr-labs/architecture-documentation/tree/main/grid-algorithm#graphical-representation-of-the-algorithm
|
||||
*/
|
||||
describe("Grid Algorithm", () => {
|
||||
test.each(itemTests)("should convert a grid with %i columns to a grid with %i columns", (_, _ignored, item) => {
|
||||
const input = generateInputFromText(item.input);
|
||||
|
||||
const result = generateResponsiveGridFor({
|
||||
items: input,
|
||||
width: item.outputColumnCount,
|
||||
previousWidth: item.inputColumnCount,
|
||||
sectionId: ROOT_SECTION_ID,
|
||||
});
|
||||
|
||||
const output = generateOutputText(result.items, item.outputColumnCount);
|
||||
|
||||
expect(output).toBe(item.output);
|
||||
});
|
||||
test.each(dynamicSectionTests)(
|
||||
"should convert a grid with dynamic sections from 16 columns to %i columns",
|
||||
(_, testInput) => {
|
||||
const outerDynamicSectionId = "b";
|
||||
const innerDynamicSectionId = "f";
|
||||
const items = [
|
||||
algoItem({ id: "a", width: 2, height: 2 }),
|
||||
algoItem({ id: outerDynamicSectionId, type: "section", width: 12, height: 3, yOffset: 2 }),
|
||||
algoItem({ id: "a", width: 2, sectionId: outerDynamicSectionId }),
|
||||
algoItem({ id: "b", width: 4, sectionId: outerDynamicSectionId, xOffset: 2 }),
|
||||
algoItem({ id: "c", width: 2, sectionId: outerDynamicSectionId, xOffset: 6 }),
|
||||
algoItem({ id: "d", width: 1, sectionId: outerDynamicSectionId, xOffset: 8 }),
|
||||
algoItem({ id: "e", width: 3, sectionId: outerDynamicSectionId, xOffset: 9 }),
|
||||
algoItem({
|
||||
id: innerDynamicSectionId,
|
||||
type: "section",
|
||||
width: 8,
|
||||
height: 2,
|
||||
yOffset: 1,
|
||||
sectionId: outerDynamicSectionId,
|
||||
}),
|
||||
algoItem({ id: "a", width: 2, sectionId: innerDynamicSectionId }),
|
||||
algoItem({ id: "b", width: 5, xOffset: 2, sectionId: innerDynamicSectionId }),
|
||||
algoItem({ id: "c", width: 1, height: 2, xOffset: 7, sectionId: innerDynamicSectionId }),
|
||||
algoItem({ id: "d", width: 7, yOffset: 1, sectionId: innerDynamicSectionId }),
|
||||
algoItem({ id: "g", width: 4, yOffset: 1, sectionId: outerDynamicSectionId, xOffset: 8 }),
|
||||
algoItem({ id: "h", width: 3, yOffset: 2, sectionId: outerDynamicSectionId, xOffset: 8 }),
|
||||
algoItem({ id: "i", width: 1, yOffset: 2, sectionId: outerDynamicSectionId, xOffset: 11 }),
|
||||
algoItem({ id: "c", width: 5, yOffset: 5 }),
|
||||
];
|
||||
|
||||
const newItems = generateResponsiveGridFor({
|
||||
items,
|
||||
width: testInput.outputColumns,
|
||||
previousWidth: 16,
|
||||
sectionId: ROOT_SECTION_ID,
|
||||
});
|
||||
|
||||
const rootItems = newItems.items.filter((item) => item.sectionId === ROOT_SECTION_ID);
|
||||
const outerSection = items.find((item) => item.id === outerDynamicSectionId);
|
||||
const outerItems = newItems.items.filter((item) => item.sectionId === outerDynamicSectionId);
|
||||
const innerSection = items.find((item) => item.id === innerDynamicSectionId);
|
||||
const innerItems = newItems.items.filter((item) => item.sectionId === innerDynamicSectionId);
|
||||
|
||||
expect(generateOutputText(rootItems, testInput.outputColumns)).toBe(testInput.root);
|
||||
expect(generateOutputText(outerItems, Math.min(testInput.outputColumns, outerSection?.width ?? 999))).toBe(
|
||||
testInput.outer,
|
||||
);
|
||||
expect(generateOutputText(innerItems, Math.min(testInput.outputColumns, innerSection?.width ?? 999))).toBe(
|
||||
testInput.inner,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const algoItem = (item: Partial<GridAlgorithmItem>): GridAlgorithmItem => ({
|
||||
id: createId(),
|
||||
type: "item",
|
||||
width: 1,
|
||||
height: 1,
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
sectionId: ROOT_SECTION_ID,
|
||||
...item,
|
||||
});
|
||||
|
||||
const sixteenColumns = `
|
||||
abbccccddddeeefg
|
||||
hbbccccddddeeeij
|
||||
klllmmmmmnneeeop
|
||||
qlllmmmmmnnrrrst
|
||||
ulllmmmmmnnrrrvw
|
||||
xyz äö`;
|
||||
|
||||
// Just add two empty columns to the right
|
||||
const eighteenColumns = sixteenColumns
|
||||
.split("\n")
|
||||
.map((line, index) => (index === 0 ? line : `${line} `))
|
||||
.join("\n");
|
||||
|
||||
const tenColumns = `
|
||||
abbcccceee
|
||||
fbbcccceee
|
||||
ddddghieee
|
||||
ddddjklllo
|
||||
mmmmmplllq
|
||||
mmmmmslllt
|
||||
mmmmmnnrrr
|
||||
uvwxynnrrr
|
||||
zäö nn `;
|
||||
|
||||
const sixColumns = `
|
||||
abbfgh
|
||||
ibbjko
|
||||
ccccnn
|
||||
ccccnn
|
||||
ddddnn
|
||||
ddddpq
|
||||
eeelll
|
||||
eeelll
|
||||
eeelll
|
||||
mmmmms
|
||||
mmmmmt
|
||||
mmmmmu
|
||||
rrrvwx
|
||||
rrryzä
|
||||
ö `;
|
||||
const threeColumns = `
|
||||
abb
|
||||
fbb
|
||||
ccc
|
||||
ccc
|
||||
ddd
|
||||
ddd
|
||||
eee
|
||||
eee
|
||||
eee
|
||||
ghi
|
||||
jko
|
||||
lll
|
||||
lll
|
||||
lll
|
||||
mmm
|
||||
mmm
|
||||
mmm
|
||||
nnp
|
||||
nnq
|
||||
nns
|
||||
rrr
|
||||
rrr
|
||||
tuv
|
||||
wxy
|
||||
zäö`;
|
||||
|
||||
const itemTests = [
|
||||
{
|
||||
input: sixteenColumns,
|
||||
inputColumnCount: 16,
|
||||
output: sixteenColumns,
|
||||
outputColumnCount: 16,
|
||||
},
|
||||
{
|
||||
input: sixteenColumns,
|
||||
inputColumnCount: 16,
|
||||
output: eighteenColumns,
|
||||
outputColumnCount: 18,
|
||||
},
|
||||
{
|
||||
input: sixteenColumns,
|
||||
inputColumnCount: 16,
|
||||
output: tenColumns,
|
||||
outputColumnCount: 10,
|
||||
},
|
||||
{
|
||||
input: sixteenColumns,
|
||||
inputColumnCount: 16,
|
||||
output: sixColumns,
|
||||
outputColumnCount: 6,
|
||||
},
|
||||
{
|
||||
input: sixteenColumns,
|
||||
inputColumnCount: 16,
|
||||
output: threeColumns,
|
||||
outputColumnCount: 3,
|
||||
},
|
||||
].map((item) => [item.inputColumnCount, item.outputColumnCount, item] as const);
|
||||
|
||||
const dynamicSectionTests = [
|
||||
{
|
||||
outputColumns: 16,
|
||||
root: `
|
||||
aa
|
||||
aa
|
||||
bbbbbbbbbbbb
|
||||
bbbbbbbbbbbb
|
||||
bbbbbbbbbbbb
|
||||
ccccc `,
|
||||
outer: `
|
||||
aabbbbccdeee
|
||||
ffffffffgggg
|
||||
ffffffffhhhi`,
|
||||
inner: `
|
||||
aabbbbbc
|
||||
dddddddc`,
|
||||
},
|
||||
{
|
||||
outputColumns: 10,
|
||||
root: `
|
||||
aaccccc
|
||||
aa
|
||||
bbbbbbbbbb
|
||||
bbbbbbbbbb
|
||||
bbbbbbbbbb
|
||||
bbbbbbbbbb`,
|
||||
outer: `
|
||||
aabbbbccdi
|
||||
eeegggghhh
|
||||
ffffffff
|
||||
ffffffff `,
|
||||
inner: `
|
||||
aabbbbbc
|
||||
dddddddc`,
|
||||
},
|
||||
{
|
||||
outputColumns: 6,
|
||||
root: `
|
||||
aa
|
||||
aa
|
||||
bbbbbb
|
||||
bbbbbb
|
||||
bbbbbb
|
||||
bbbbbb
|
||||
bbbbbb
|
||||
bbbbbb
|
||||
bbbbbb
|
||||
ccccc `,
|
||||
outer: `
|
||||
aabbbb
|
||||
ccdeee
|
||||
ffffff
|
||||
ffffff
|
||||
ffffff
|
||||
ggggi
|
||||
hhh `,
|
||||
inner: `
|
||||
aa c
|
||||
bbbbbc
|
||||
dddddd`,
|
||||
},
|
||||
{
|
||||
outputColumns: 3,
|
||||
root: `
|
||||
aa
|
||||
aa
|
||||
bbb
|
||||
bbb
|
||||
bbb
|
||||
bbb
|
||||
bbb
|
||||
bbb
|
||||
bbb
|
||||
bbb
|
||||
bbb
|
||||
bbb
|
||||
bbb
|
||||
ccc`,
|
||||
outer: `
|
||||
aad
|
||||
bbb
|
||||
cci
|
||||
eee
|
||||
fff
|
||||
fff
|
||||
fff
|
||||
fff
|
||||
fff
|
||||
ggg
|
||||
hhh`,
|
||||
inner: `
|
||||
aa
|
||||
bbb
|
||||
c
|
||||
c
|
||||
ddd`,
|
||||
},
|
||||
].map((item) => [item.outputColumns, item] as const);
|
||||
|
||||
const generateInputFromText = (text: string) => {
|
||||
const lines = text.split("\n").slice(1); // Remove first empty row
|
||||
const items: GridAlgorithmItem[] = [];
|
||||
for (let yOffset = 0; yOffset < lines.length; yOffset++) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const line = lines[yOffset]!;
|
||||
for (let xOffset = 0; xOffset < line.length; xOffset++) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const char = line[xOffset]!;
|
||||
if (char === " ") continue;
|
||||
if (items.some((item) => item.id === char)) continue;
|
||||
items.push({
|
||||
id: char,
|
||||
type: "item",
|
||||
width: getWidth(line, xOffset, char),
|
||||
height: getHeight(lines, { x: xOffset, y: yOffset }, char),
|
||||
xOffset,
|
||||
yOffset,
|
||||
sectionId: ROOT_SECTION_ID,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
};
|
||||
|
||||
const generateOutputText = (items: GridAlgorithmItem[], columnCount: number) => {
|
||||
const occupied2d: string[][] = [];
|
||||
for (const item of items) {
|
||||
addItemToOccupied(occupied2d, item, { x: item.xOffset, y: item.yOffset }, columnCount);
|
||||
}
|
||||
|
||||
return `\n${occupied2d.map((row) => row.join("")).join("\n")}`;
|
||||
};
|
||||
|
||||
const getWidth = (line: string, offset: number, char: string) => {
|
||||
const row = line.split("");
|
||||
let width = 1;
|
||||
for (let xOffset = offset + 1; xOffset < row.length; xOffset++) {
|
||||
if (row[xOffset] === char) {
|
||||
width++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return width;
|
||||
};
|
||||
|
||||
const getHeight = (lines: string[], position: { x: number; y: number }, char: string) => {
|
||||
let height = 1;
|
||||
for (let yOffset = position.y + 1; yOffset < lines.length; yOffset++) {
|
||||
if (lines[yOffset]?.[position.x] === char) {
|
||||
height++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return height;
|
||||
};
|
||||
|
||||
const addItemToOccupied = (
|
||||
occupied2d: string[][],
|
||||
item: GridAlgorithmItem,
|
||||
position: { x: number; y: number },
|
||||
columnCount: number,
|
||||
) => {
|
||||
for (let yOffset = 0; yOffset < item.height; yOffset++) {
|
||||
let row = occupied2d[position.y + yOffset];
|
||||
if (!row) {
|
||||
addRow(occupied2d, columnCount);
|
||||
// After adding it, it must exist
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
row = occupied2d[position.y + yOffset]!;
|
||||
}
|
||||
|
||||
for (let xOffset = 0; xOffset < item.width; xOffset++) {
|
||||
row[position.x + xOffset] = item.id;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const addRow = (occupied2d: string[][], columnCount: number) => {
|
||||
occupied2d.push(new Array<string>(columnCount).fill(" "));
|
||||
};
|
||||
Reference in New Issue
Block a user