mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-21 06:59:09 +00:00
Project manager (#697)
* Project manager: Load, Rename, Duplicate (#712) * Project Manager: Create, Delete (#723)
This commit is contained in:
@@ -42,12 +42,5 @@ export function logAxiosError(prepend: string, error: unknown) {
|
|||||||
* Utility function invalidates react-query caches
|
* Utility function invalidates react-query caches
|
||||||
*/
|
*/
|
||||||
export async function invalidateAllCaches() {
|
export async function invalidateAllCaches() {
|
||||||
await ontimeQueryClient.invalidateQueries({ queryKey: ['project'] });
|
await ontimeQueryClient.invalidateQueries();
|
||||||
await ontimeQueryClient.invalidateQueries({ queryKey: ['aliases'] });
|
|
||||||
await ontimeQueryClient.invalidateQueries({ queryKey: ['userFields'] });
|
|
||||||
await ontimeQueryClient.invalidateQueries({ queryKey: ['rundown'] });
|
|
||||||
await ontimeQueryClient.invalidateQueries({ queryKey: ['appinfo'] });
|
|
||||||
await ontimeQueryClient.invalidateQueries({ queryKey: ['oscSettings'] });
|
|
||||||
await ontimeQueryClient.invalidateQueries({ queryKey: ['appSettings'] });
|
|
||||||
await ontimeQueryClient.invalidateQueries({ queryKey: ['viewSettings'] });
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
UserFields,
|
UserFields,
|
||||||
ViewSettings,
|
ViewSettings,
|
||||||
} from 'ontime-types';
|
} from 'ontime-types';
|
||||||
|
import { MessageResponse } from 'ontime-types';
|
||||||
import { ExcelImportMap } from 'ontime-utils';
|
import { ExcelImportMap } from 'ontime-utils';
|
||||||
|
|
||||||
import { apiRepoLatest } from '../../externals';
|
import { apiRepoLatest } from '../../externals';
|
||||||
@@ -257,6 +258,16 @@ export async function getProjects(): Promise<ProjectFileListResponse> {
|
|||||||
return res.data;
|
return res.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description HTTP request to load a project file
|
||||||
|
*/
|
||||||
|
export async function loadProject(filename: string): Promise<MessageResponse> {
|
||||||
|
const res = await axios.post(`${ontimeURL}/load-project`, {
|
||||||
|
filename,
|
||||||
|
});
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description STEP 1
|
* @description STEP 1
|
||||||
*/
|
*/
|
||||||
@@ -329,3 +340,49 @@ export const postPushSheet = async (id: string, options: ExcelImportMap) => {
|
|||||||
const response = await axios.post(`${ontimeURL}/sheet-push`, { id, options });
|
const response = await axios.post(`${ontimeURL}/sheet-push`, { id, options });
|
||||||
return response.data.data;
|
return response.data.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description HTTP request to rename a project file
|
||||||
|
*/
|
||||||
|
export async function renameProject(filename: string, newFilename: string): Promise<MessageResponse> {
|
||||||
|
const url = `${ontimeURL}/project/${filename}/rename`;
|
||||||
|
const decodedUrl = decodeURIComponent(url);
|
||||||
|
const res = await axios.put(decodedUrl, {
|
||||||
|
newFilename,
|
||||||
|
});
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description HTTP request to duplicate a project file
|
||||||
|
*/
|
||||||
|
export async function duplicateProject(filename: string, newFilename: string): Promise<MessageResponse> {
|
||||||
|
const url = `${ontimeURL}/project/${filename}/duplicate`;
|
||||||
|
const decodedUrl = decodeURIComponent(url);
|
||||||
|
const res = await axios.post(decodedUrl, {
|
||||||
|
newFilename,
|
||||||
|
});
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description HTTP request to delete a project file
|
||||||
|
*/
|
||||||
|
export async function deleteProject(filename: string): Promise<MessageResponse> {
|
||||||
|
const url = `${ontimeURL}/project/${filename}`;
|
||||||
|
const decodedUrl = decodeURIComponent(url);
|
||||||
|
const res = await axios.delete(decodedUrl);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description HTTP request to create a project file
|
||||||
|
*/
|
||||||
|
export async function createProject(filename: string): Promise<MessageResponse> {
|
||||||
|
const url = `${ontimeURL}/project`;
|
||||||
|
const decodedUrl = decodeURIComponent(url);
|
||||||
|
const res = await axios.post(decodedUrl, {
|
||||||
|
filename,
|
||||||
|
});
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const placeholderProjectList: ProjectFileListResponse = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function useProjectList() {
|
export function useProjectList() {
|
||||||
const { data, status } = useQuery({
|
const { data, status, refetch } = useQuery({
|
||||||
queryKey: PROJECT_LIST,
|
queryKey: PROJECT_LIST,
|
||||||
queryFn: getProjects,
|
queryFn: getProjects,
|
||||||
placeholderData: placeholderProjectList,
|
placeholderData: placeholderProjectList,
|
||||||
@@ -20,5 +20,5 @@ export function useProjectList() {
|
|||||||
refetchInterval: queryRefetchIntervalSlow,
|
refetchInterval: queryRefetchIntervalSlow,
|
||||||
networkMode: 'always',
|
networkMode: 'always',
|
||||||
});
|
});
|
||||||
return { data: data ?? placeholderProjectList, status };
|
return { data: data ?? placeholderProjectList, status, refetch };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { Button, Input } from '@chakra-ui/react';
|
||||||
|
|
||||||
|
import style from './ProjectPanel.module.scss';
|
||||||
|
|
||||||
|
export type ProjectFormValues = {
|
||||||
|
filename: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ProjectFormProps {
|
||||||
|
action: 'duplicate' | 'rename' | 'create';
|
||||||
|
filename: string;
|
||||||
|
onCancel: () => void;
|
||||||
|
onSubmit: (values: ProjectFormValues) => Promise<void>;
|
||||||
|
submitError: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProjectForm({ action, filename, onSubmit, onCancel, submitError }: ProjectFormProps) {
|
||||||
|
const {
|
||||||
|
handleSubmit,
|
||||||
|
register,
|
||||||
|
formState: { isSubmitting, isDirty, isValid },
|
||||||
|
setFocus,
|
||||||
|
} = useForm<ProjectFormValues>({
|
||||||
|
defaultValues: { filename },
|
||||||
|
values: { filename },
|
||||||
|
resetOptions: {
|
||||||
|
keepDirtyValues: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setFocus('filename');
|
||||||
|
}, [setFocus]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className={style.form}>
|
||||||
|
<Input
|
||||||
|
className={style.formInput}
|
||||||
|
id='filename'
|
||||||
|
size='sm'
|
||||||
|
type='text'
|
||||||
|
variant='ontime-filled'
|
||||||
|
placeholder='Enter new name'
|
||||||
|
autoComplete='off'
|
||||||
|
{...register('filename')}
|
||||||
|
/>
|
||||||
|
<div className={style.actionButtons}>
|
||||||
|
<Button onClick={onCancel} size='sm' variant='ontime-ghosted' disabled={isSubmitting}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size='sm'
|
||||||
|
variant='ontime-filled'
|
||||||
|
isDisabled={!isDirty || !isValid || isSubmitting}
|
||||||
|
type='submit'
|
||||||
|
className={style.saveButton}
|
||||||
|
>
|
||||||
|
{action}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{submitError && <span className={style.error}>{submitError}</span>}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,19 +1,72 @@
|
|||||||
import { IconButton, Menu, MenuButton, MenuItem, MenuList } from '@chakra-ui/react';
|
import { useMemo, useState } from 'react';
|
||||||
import { IoEllipsisHorizontal } from '@react-icons/all-files/io5/IoEllipsisHorizontal';
|
|
||||||
|
|
||||||
|
import { maybeAxiosError } from '../../../../common/api/apiUtils';
|
||||||
|
import { createProject } from '../../../../common/api/ontimeApi';
|
||||||
import { useProjectList } from '../../../../common/hooks-query/useProjectList';
|
import { useProjectList } from '../../../../common/hooks-query/useProjectList';
|
||||||
import * as Panel from '../PanelUtils';
|
import * as Panel from '../PanelUtils';
|
||||||
|
|
||||||
|
import ProjectForm, { ProjectFormValues } from './ProjectForm';
|
||||||
|
import ProjectListItem, { EditMode } from './ProjectListItem';
|
||||||
|
|
||||||
import style from './ProjectPanel.module.scss';
|
import style from './ProjectPanel.module.scss';
|
||||||
|
|
||||||
export default function ProjectList() {
|
interface ProjectListProps {
|
||||||
const { data } = useProjectList();
|
isCreatingProject: boolean;
|
||||||
|
onToggleCreate: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProjectList({ isCreatingProject, onToggleCreate }: ProjectListProps) {
|
||||||
|
const { data, refetch } = useProjectList();
|
||||||
const { files, lastLoadedProject } = data;
|
const { files, lastLoadedProject } = data;
|
||||||
|
|
||||||
// extract currently loaded from file list
|
const [editingMode, setEditingMode] = useState<EditMode | null>(null);
|
||||||
const currentlyLoadedIndex = files.findIndex((project) => project.filename === lastLoadedProject);
|
const [editingFilename, setEditingFilename] = useState<string | null>(null);
|
||||||
const projectFiles = [...files];
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||||
const current = projectFiles.splice(currentlyLoadedIndex, 1)[0];
|
|
||||||
|
const handleToggleCreateMode = () => {
|
||||||
|
onToggleCreate();
|
||||||
|
setSubmitError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmitCreate = async (values: ProjectFormValues) => {
|
||||||
|
try {
|
||||||
|
setSubmitError(null);
|
||||||
|
const filename = values.filename.trim();
|
||||||
|
if (!filename) {
|
||||||
|
setSubmitError('Project name cannot be empty');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await createProject(filename);
|
||||||
|
await refetch();
|
||||||
|
handleToggleCreateMode();
|
||||||
|
} catch (error) {
|
||||||
|
setSubmitError(maybeAxiosError(error));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleEditMode = (editMode: EditMode, filename: string | null) => {
|
||||||
|
setEditingMode((prev) => (prev === editMode && filename === editingFilename ? null : editMode));
|
||||||
|
setEditingFilename(filename);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClear = () => {
|
||||||
|
setEditingMode(null);
|
||||||
|
setEditingFilename(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRefetch = async () => {
|
||||||
|
await refetch();
|
||||||
|
};
|
||||||
|
|
||||||
|
const reorderedProjectFiles = useMemo(() => {
|
||||||
|
if (!data?.files?.length) return [];
|
||||||
|
|
||||||
|
const currentlyLoadedIndex = files.findIndex((project) => project.filename === lastLoadedProject);
|
||||||
|
const projectFiles = [...files];
|
||||||
|
const current = projectFiles.splice(currentlyLoadedIndex, 1)?.[0];
|
||||||
|
|
||||||
|
return [current, ...projectFiles];
|
||||||
|
}, [data.files.length, files, lastLoadedProject]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel.Table>
|
<Panel.Table>
|
||||||
@@ -26,51 +79,35 @@ export default function ProjectList() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{current && (
|
{isCreatingProject ? (
|
||||||
<tr className={style.current}>
|
<tr className={style.createContainer}>
|
||||||
<td>{current.filename}</td>
|
<td colSpan={99}>
|
||||||
<td>{new Date(current.createdAt).toLocaleString()}</td>
|
<ProjectForm
|
||||||
<td>{new Date(current.updatedAt).toLocaleString()}</td>
|
action='create'
|
||||||
<td className={style.actionButton}>
|
filename=''
|
||||||
<ActionMenu />
|
onSubmit={handleSubmitCreate}
|
||||||
|
onCancel={handleToggleCreateMode}
|
||||||
|
submitError=''
|
||||||
|
/>
|
||||||
|
{submitError && <span className={style.createSubmitError}>{submitError}</span>}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
) : null}
|
||||||
{projectFiles.map((project) => {
|
{reorderedProjectFiles.map((project) => (
|
||||||
const createdAt = new Date(project.createdAt).toLocaleString();
|
<ProjectListItem
|
||||||
const updatedAt = new Date(project.updatedAt).toLocaleString();
|
key={project.filename}
|
||||||
return (
|
filename={project.filename}
|
||||||
<tr key={project.filename}>
|
createdAt={project.createdAt}
|
||||||
<td>{project.filename}</td>
|
updatedAt={project.updatedAt}
|
||||||
<td>{createdAt}</td>
|
onToggleEditMode={handleToggleEditMode}
|
||||||
<td>{updatedAt}</td>
|
onSubmit={handleClear}
|
||||||
<td className={style.actionButton}>
|
onRefetch={handleRefetch}
|
||||||
<ActionMenu />
|
editingFilename={editingFilename}
|
||||||
</td>
|
editingMode={editingMode}
|
||||||
</tr>
|
current={project.filename === lastLoadedProject}
|
||||||
);
|
/>
|
||||||
})}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</Panel.Table>
|
</Panel.Table>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ActionMenu() {
|
|
||||||
return (
|
|
||||||
<Menu variant='ontime-on-dark' size='sm'>
|
|
||||||
<MenuButton
|
|
||||||
as={IconButton}
|
|
||||||
aria-label='Options'
|
|
||||||
icon={<IoEllipsisHorizontal />}
|
|
||||||
variant='ontime-ghosted'
|
|
||||||
size='sm'
|
|
||||||
/>
|
|
||||||
<MenuList>
|
|
||||||
<MenuItem>Load</MenuItem>
|
|
||||||
<MenuItem>Rename</MenuItem>
|
|
||||||
<MenuItem>Duplicate</MenuItem>
|
|
||||||
<MenuItem>Delete</MenuItem>
|
|
||||||
</MenuList>
|
|
||||||
</Menu>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { IconButton, Menu, MenuButton, MenuItem, MenuList } from '@chakra-ui/react';
|
||||||
|
import { IoEllipsisHorizontal } from '@react-icons/all-files/io5/IoEllipsisHorizontal';
|
||||||
|
|
||||||
|
import { invalidateAllCaches, maybeAxiosError } from '../../../../common/api/apiUtils';
|
||||||
|
import { deleteProject, duplicateProject, loadProject, renameProject } from '../../../../common/api/ontimeApi';
|
||||||
|
|
||||||
|
import ProjectForm, { ProjectFormValues } from './ProjectForm';
|
||||||
|
|
||||||
|
import style from './ProjectPanel.module.scss';
|
||||||
|
|
||||||
|
export type EditMode = 'rename' | 'duplicate' | null;
|
||||||
|
|
||||||
|
interface ProjectListItemProps {
|
||||||
|
current?: boolean;
|
||||||
|
filename: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
onToggleEditMode: (editMode: EditMode, filename: string | null) => void;
|
||||||
|
onSubmit: () => void;
|
||||||
|
onRefetch: () => Promise<void>;
|
||||||
|
editingFilename: string | null;
|
||||||
|
editingMode: EditMode | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProjectListItem({
|
||||||
|
current,
|
||||||
|
createdAt,
|
||||||
|
editingFilename,
|
||||||
|
editingMode,
|
||||||
|
filename,
|
||||||
|
onRefetch,
|
||||||
|
onSubmit,
|
||||||
|
onToggleEditMode,
|
||||||
|
updatedAt,
|
||||||
|
}: ProjectListItemProps) {
|
||||||
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleSubmitRename = async (values: ProjectFormValues) => {
|
||||||
|
try {
|
||||||
|
setSubmitError(null);
|
||||||
|
|
||||||
|
if (!values.filename) {
|
||||||
|
setSubmitError('Filename cannot be blank');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await renameProject(filename, values.filename);
|
||||||
|
await onRefetch();
|
||||||
|
onSubmit();
|
||||||
|
} catch (error) {
|
||||||
|
setSubmitError(maybeAxiosError(error));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmitDuplicate = async (values: ProjectFormValues) => {
|
||||||
|
try {
|
||||||
|
setSubmitError(null);
|
||||||
|
|
||||||
|
if (!values.filename) {
|
||||||
|
setSubmitError('Filename cannot be blank');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await duplicateProject(filename, values.filename);
|
||||||
|
await onRefetch();
|
||||||
|
onSubmit();
|
||||||
|
} catch (error) {
|
||||||
|
setSubmitError(maybeAxiosError(error));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleEditMode = (editMode: EditMode, filename: string | null) => {
|
||||||
|
setSubmitError(null);
|
||||||
|
onToggleEditMode(editMode, filename);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
handleToggleEditMode(null, null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isCurrentlyBeingEdited = editingMode && filename === editingFilename;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={filename} className={current ? style.current : undefined}>
|
||||||
|
{isCurrentlyBeingEdited ? (
|
||||||
|
<td colSpan={99}>
|
||||||
|
<ProjectForm
|
||||||
|
action={editingMode}
|
||||||
|
filename={filename}
|
||||||
|
onSubmit={editingMode === 'duplicate' ? handleSubmitDuplicate : handleSubmitRename}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
submitError={submitError}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<td>{filename}</td>
|
||||||
|
<td>{new Date(createdAt).toLocaleString()}</td>
|
||||||
|
<td>{new Date(updatedAt).toLocaleString()}</td>
|
||||||
|
<td className={style.actionButton}>
|
||||||
|
<ActionMenu
|
||||||
|
current={current}
|
||||||
|
filename={filename}
|
||||||
|
onChangeEditMode={handleToggleEditMode}
|
||||||
|
onRefetch={onRefetch}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActionMenu({
|
||||||
|
current,
|
||||||
|
filename,
|
||||||
|
onChangeEditMode,
|
||||||
|
onRefetch,
|
||||||
|
}: {
|
||||||
|
current?: boolean;
|
||||||
|
filename: string;
|
||||||
|
onChangeEditMode: (editMode: EditMode, filename: string) => void;
|
||||||
|
onRefetch: () => Promise<void>;
|
||||||
|
}) {
|
||||||
|
const handleLoad = async () => {
|
||||||
|
await loadProject(filename);
|
||||||
|
await invalidateAllCaches();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRename = () => {
|
||||||
|
onChangeEditMode('rename', filename);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDuplicate = () => {
|
||||||
|
onChangeEditMode('duplicate', filename);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
await deleteProject(filename);
|
||||||
|
await onRefetch();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Menu variant='ontime-on-dark' size='sm'>
|
||||||
|
<MenuButton
|
||||||
|
as={IconButton}
|
||||||
|
aria-label='Options'
|
||||||
|
icon={<IoEllipsisHorizontal />}
|
||||||
|
variant='ontime-ghosted'
|
||||||
|
size='sm'
|
||||||
|
/>
|
||||||
|
<MenuList>
|
||||||
|
<MenuItem onClick={handleLoad} isDisabled={current}>
|
||||||
|
Load
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem onClick={handleRename}>Rename</MenuItem>
|
||||||
|
<MenuItem onClick={handleDuplicate}>Duplicate</MenuItem>
|
||||||
|
<MenuItem isDisabled={current} onClick={handleDelete}>
|
||||||
|
Delete
|
||||||
|
</MenuItem>
|
||||||
|
</MenuList>
|
||||||
|
</Menu>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,5 +4,42 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.actionButton {
|
.actionButton {
|
||||||
|
flex: 1;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formInput {
|
||||||
|
flex: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionButtons {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-left: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: $red-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.createContainer {
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.saveButton {
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.createSubmitError {
|
||||||
|
font-size: calc(1rem - 2px);
|
||||||
|
font-weight: 400;
|
||||||
|
color: $red-500;
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
import { Button } from '@chakra-ui/react';
|
import { Button } from '@chakra-ui/react';
|
||||||
|
|
||||||
import * as Panel from '../PanelUtils';
|
import * as Panel from '../PanelUtils';
|
||||||
@@ -5,6 +6,12 @@ import * as Panel from '../PanelUtils';
|
|||||||
import ProjectList from './ProjectList';
|
import ProjectList from './ProjectList';
|
||||||
|
|
||||||
export default function ProjectPanel() {
|
export default function ProjectPanel() {
|
||||||
|
const [isCreatingProject, setIsCreatingProject] = useState(false);
|
||||||
|
|
||||||
|
const handleToggleCreate = () => {
|
||||||
|
setIsCreatingProject((prev) => !prev);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Panel.Header>Project</Panel.Header>
|
<Panel.Header>Project</Panel.Header>
|
||||||
@@ -12,9 +19,11 @@ export default function ProjectPanel() {
|
|||||||
<Panel.Card>
|
<Panel.Card>
|
||||||
<Panel.SubHeader>
|
<Panel.SubHeader>
|
||||||
Manage projects
|
Manage projects
|
||||||
<Button variant='ontime-filled'>New</Button>
|
<Button variant='ontime-filled' onClick={handleToggleCreate}>
|
||||||
|
New
|
||||||
|
</Button>
|
||||||
</Panel.SubHeader>
|
</Panel.SubHeader>
|
||||||
<ProjectList />
|
<ProjectList onToggleCreate={handleToggleCreate} isCreatingProject={isCreatingProject} />
|
||||||
</Panel.Card>
|
</Panel.Card>
|
||||||
</Panel.Section>
|
</Panel.Section>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import { deleteFile } from '../utils/parserUtils.js';
|
|||||||
import { validateProjectFiles } from './ontimeController.validate.js';
|
import { validateProjectFiles } from './ontimeController.validate.js';
|
||||||
import { dbModel } from '../models/dataModel.js';
|
import { dbModel } from '../models/dataModel.js';
|
||||||
import { sheet } from '../utils/sheetsAuth.js';
|
import { sheet } from '../utils/sheetsAuth.js';
|
||||||
|
import { removeFileExtension } from '../utils/removeFileExtension.js';
|
||||||
|
|
||||||
// Create controller for GET request to '/ontime/poll'
|
// Create controller for GET request to '/ontime/poll'
|
||||||
// Returns data for current state
|
// Returns data for current state
|
||||||
@@ -491,9 +492,11 @@ export const listProjects: RequestHandler = async (_, res: Response<ProjectFileL
|
|||||||
|
|
||||||
const lastLoadedProject = JSON.parse(fs.readFileSync(lastLoadedProjectConfigPath, 'utf8')).lastLoadedProject;
|
const lastLoadedProject = JSON.parse(fs.readFileSync(lastLoadedProjectConfigPath, 'utf8')).lastLoadedProject;
|
||||||
|
|
||||||
|
const lastLoadedProjectName = removeFileExtension(lastLoadedProject);
|
||||||
|
|
||||||
res.status(200).send({
|
res.status(200).send({
|
||||||
files: fileList,
|
files: fileList,
|
||||||
lastLoadedProject,
|
lastLoadedProject: lastLoadedProjectName,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).send({ message: error.toString() });
|
res.status(500).send({ message: error.toString() });
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ router.post('/new', projectSanitiser, postNew);
|
|||||||
router.get('/projects', listProjects);
|
router.get('/projects', listProjects);
|
||||||
|
|
||||||
// create route between controller and '/ontime/load-project' endpoint
|
// create route between controller and '/ontime/load-project' endpoint
|
||||||
router.post('/load-project', validateLoadProjectFile, loadProject);
|
router.post('/load-project', validateLoadProjectFile, sanitizeProjectFilename, loadProject);
|
||||||
|
|
||||||
// create route between controller and '/ontime/project/:filename/duplicate' endpoint
|
// create route between controller and '/ontime/project/:filename/duplicate' endpoint
|
||||||
router.post('/project/:filename/duplicate', validateProjectDuplicate, sanitizeProjectFilename, duplicateProjectFile);
|
router.post('/project/:filename/duplicate', validateProjectDuplicate, sanitizeProjectFilename, duplicateProjectFile);
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ describe('getProjectFiles test', () => {
|
|||||||
|
|
||||||
const result = await getProjectFiles();
|
const result = await getProjectFiles();
|
||||||
|
|
||||||
const expectedFiles = ['file1.json', 'file2.json', 'file3.json'].map((file) => ({
|
const expectedFiles = ['file1', 'file2', 'file3'].map((file) => ({
|
||||||
filename: file,
|
filename: file,
|
||||||
createdAt: new Date('2020-01-01').toISOString(),
|
createdAt: new Date('2020-01-01').toISOString(),
|
||||||
updatedAt: new Date('2021-01-01').toISOString(),
|
updatedAt: new Date('2021-01-01').toISOString(),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { getAppDataPath } from '../setup.js';
|
|||||||
|
|
||||||
import { extname, join } from 'path';
|
import { extname, join } from 'path';
|
||||||
import { readdir, stat } from 'fs/promises';
|
import { readdir, stat } from 'fs/promises';
|
||||||
|
import { removeFileExtension } from './removeFileExtension.js';
|
||||||
|
|
||||||
const getFilesFromFolder = async (folderPath: string) => {
|
const getFilesFromFolder = async (folderPath: string) => {
|
||||||
return await readdir(folderPath);
|
return await readdir(folderPath);
|
||||||
@@ -41,7 +42,7 @@ export const getProjectFiles = async (): Promise<ProjectFile[]> => {
|
|||||||
const stats = await stat(filePath);
|
const stats = await stat(filePath);
|
||||||
|
|
||||||
projectFiles.push({
|
projectFiles.push({
|
||||||
filename: file,
|
filename: removeFileExtension(file),
|
||||||
createdAt: stats.birthtime.toISOString(),
|
createdAt: stats.birthtime.toISOString(),
|
||||||
updatedAt: stats.mtime.toISOString(),
|
updatedAt: stats.mtime.toISOString(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { parse } from 'path';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description Takes a filename and removes the extension
|
||||||
|
* @param {string} filename - filename with extension
|
||||||
|
*/
|
||||||
|
export const removeFileExtension = (filename: string): string => {
|
||||||
|
return parse(filename).name;
|
||||||
|
};
|
||||||
@@ -6,6 +6,28 @@ import { EXCEL_MIME, JSON_MIME } from './parser.js';
|
|||||||
import { ensureDirectory } from './fileManagement.js';
|
import { ensureDirectory } from './fileManagement.js';
|
||||||
import { getAppDataPath } from '../setup.js';
|
import { getAppDataPath } from '../setup.js';
|
||||||
|
|
||||||
|
function generateNewFileName(filePath, callback) {
|
||||||
|
let baseName = path.basename(filePath, path.extname(filePath));
|
||||||
|
let extension = path.extname(filePath);
|
||||||
|
let counter = 1;
|
||||||
|
|
||||||
|
const checkExistence = (newPath) => {
|
||||||
|
fs.access(newPath, fs.constants.F_OK, (err) => {
|
||||||
|
if (err) {
|
||||||
|
// File with the new name does not exist, use this name
|
||||||
|
callback(path.basename(newPath));
|
||||||
|
} else {
|
||||||
|
// File exists, increment the counter and try again
|
||||||
|
newPath = path.join(path.dirname(filePath), `${baseName} (${++counter})${extension}`);
|
||||||
|
checkExistence(newPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
let newPath = path.join(path.dirname(filePath), `${baseName} (${counter})${extension}`);
|
||||||
|
checkExistence(newPath);
|
||||||
|
}
|
||||||
|
|
||||||
// Define multer storage object
|
// Define multer storage object
|
||||||
const storage = multer.diskStorage({
|
const storage = multer.diskStorage({
|
||||||
destination: function (req, file, cb) {
|
destination: function (req, file, cb) {
|
||||||
@@ -25,8 +47,10 @@ const storage = multer.diskStorage({
|
|||||||
// File does not exist, can safely proceed to this destination
|
// File does not exist, can safely proceed to this destination
|
||||||
cb(null, uploadsPath);
|
cb(null, uploadsPath);
|
||||||
} else {
|
} else {
|
||||||
// File already exists, handle error
|
generateNewFileName(filePath, (newName) => {
|
||||||
return cb(new Error('File already exists'), false);
|
file.originalname = newName;
|
||||||
|
cb(null, uploadsPath);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ export type ProjectFileListResponse = {
|
|||||||
lastLoadedProject: string;
|
lastLoadedProject: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ErrorResponse = {
|
export type MessageResponse = {
|
||||||
message: string;
|
message: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ErrorResponse = MessageResponse;
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export type {
|
|||||||
ProjectFile,
|
ProjectFile,
|
||||||
ErrorResponse,
|
ErrorResponse,
|
||||||
ProjectFileListResponse,
|
ProjectFileListResponse,
|
||||||
|
MessageResponse,
|
||||||
} from './api/ontime-controller/BackendResponse.type.js';
|
} from './api/ontime-controller/BackendResponse.type.js';
|
||||||
export type { GetRundownCached } from './api/rundown-controller/BackendResponse.type.js';
|
export type { GetRundownCached } from './api/rundown-controller/BackendResponse.type.js';
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user