Merge branch 'fix/misc-fixes' into 'main'
TASKS-1832 See merge request crux/admin!31
This commit is contained in:
@@ -13,6 +13,22 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed File Manager operations on directories without a `.keep` marker. A directory a file was
|
||||||
|
uploaded into, or one that exists in the bucket but was not created through the app, was listed
|
||||||
|
but could not be renamed, deleted or resolved (`E_DIR_NOT_FOUND`); directory existence now falls
|
||||||
|
back to the prefix holding any entry instead of relying on the marker alone.
|
||||||
|
- Fixed renaming or deleting a folder destroying sibling folders whose name shares its prefix
|
||||||
|
(renaming `aa` overwrote and then deleted `aab`). Drive strips the trailing slash off a listing
|
||||||
|
prefix, so a directory's contents are now filtered on the real `<path>/` prefix, and the S3
|
||||||
|
service deletes the listed keys instead of passing that prefix to `deleteAll`.
|
||||||
|
- Moving, copying and deleting a folder now follow the listing pagination token, so folders holding
|
||||||
|
more than 1000 objects are no longer processed only in part.
|
||||||
|
- Fixed `getEntry` raising a bare `Error` (HTTP 500) for a path that is neither a file nor a
|
||||||
|
directory; it now raises the `E_DIR_NOT_FOUND` 404.
|
||||||
|
- Fixed the web container running against the host's `node_modules`: the `web` service keeps
|
||||||
|
`apps/web/node_modules` in an anonymous volume, so the bind mount no longer shadows the
|
||||||
|
dependencies installed in the image.
|
||||||
|
|
||||||
### Removed
|
### Removed
|
||||||
|
|
||||||
## [1.3.0] - 2026-08-28
|
## [1.3.0] - 2026-08-28
|
||||||
|
|||||||
@@ -2,34 +2,30 @@ import { axiosClient } from '#infrastructure/axios';
|
|||||||
import type { ListQueryParams, SuccessListResponse, SuccessMutationResponse } from '#infrastructure/reactQuery';
|
import type { ListQueryParams, SuccessListResponse, SuccessMutationResponse } from '#infrastructure/reactQuery';
|
||||||
import type { AdminDTO } from './AccessControlDTO';
|
import type { AdminDTO } from './AccessControlDTO';
|
||||||
|
|
||||||
const ROOT_API_ENDPOINT = '/admin';
|
|
||||||
|
|
||||||
export default class AdminAPI {
|
export default class AdminAPI {
|
||||||
getList = async (params: ListQueryParams) => {
|
getList = async (params: ListQueryParams) => {
|
||||||
return axiosClient
|
return axiosClient
|
||||||
.get<SuccessListResponse<AdminDTO>>(ROOT_API_ENDPOINT, { params })
|
.get<SuccessListResponse<AdminDTO>>('/admin', { params })
|
||||||
.then((data) => data.data);
|
.then((data) => data.data);
|
||||||
};
|
};
|
||||||
|
|
||||||
getDetailed = async (id: string) => {
|
getDetailed = async (id: string) => {
|
||||||
return axiosClient.get<AdminDTO>(`${ROOT_API_ENDPOINT}/${id}`).then((data) => data.data);
|
return axiosClient.get<AdminDTO>(`/admin/${id}`).then((data) => data.data);
|
||||||
};
|
};
|
||||||
|
|
||||||
create = async (payload: Record<string, unknown>) => {
|
create = async (payload: Record<string, unknown>) => {
|
||||||
return axiosClient
|
return axiosClient.post<SuccessMutationResponse>('/admin', payload).then((data) => data.data);
|
||||||
.post<SuccessMutationResponse>(ROOT_API_ENDPOINT, payload)
|
|
||||||
.then((data) => data.data);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
update = async (payload: { id: number } & Record<string, unknown>) => {
|
update = async (payload: { id: number } & Record<string, unknown>) => {
|
||||||
return axiosClient
|
return axiosClient
|
||||||
.put<SuccessMutationResponse>(`${ROOT_API_ENDPOINT}/${payload.id}`, payload)
|
.put<SuccessMutationResponse>(`/admin/${payload.id}`, payload)
|
||||||
.then((data) => data.data);
|
.then((data) => data.data);
|
||||||
};
|
};
|
||||||
|
|
||||||
delete = async (payload: { id: number }) => {
|
delete = async (payload: { id: number }) => {
|
||||||
return axiosClient
|
return axiosClient
|
||||||
.delete<SuccessMutationResponse>(`${ROOT_API_ENDPOINT}/${payload.id}`)
|
.delete<SuccessMutationResponse>(`/admin/${payload.id}`)
|
||||||
.then((data) => data.data);
|
.then((data) => data.data);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import { axiosClient } from '#infrastructure/axios';
|
import { axiosClient } from '#infrastructure/axios';
|
||||||
import type { PermissionDTO } from './AccessControlDTO';
|
import type { PermissionDTO } from './AccessControlDTO';
|
||||||
|
|
||||||
const ROOT_API_ENDPOINT = '/permission';
|
|
||||||
|
|
||||||
export default class PermissionAPI {
|
export default class PermissionAPI {
|
||||||
getList = async () => {
|
getList = async () => {
|
||||||
return axiosClient.get<Array<PermissionDTO>>(ROOT_API_ENDPOINT).then((data) => data.data);
|
return axiosClient.get<Array<PermissionDTO>>('/permission').then((data) => data.data);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,34 +2,30 @@ import { axiosClient } from '#infrastructure/axios';
|
|||||||
import type { ListQueryParams, SuccessListResponse, SuccessMutationResponse } from '#infrastructure/reactQuery';
|
import type { ListQueryParams, SuccessListResponse, SuccessMutationResponse } from '#infrastructure/reactQuery';
|
||||||
import type { RoleDTO } from './AccessControlDTO';
|
import type { RoleDTO } from './AccessControlDTO';
|
||||||
|
|
||||||
const ROOT_API_ENDPOINT = '/role';
|
|
||||||
|
|
||||||
export default class RoleAPI {
|
export default class RoleAPI {
|
||||||
getList = async (params: ListQueryParams) => {
|
getList = async (params: ListQueryParams) => {
|
||||||
return axiosClient
|
return axiosClient
|
||||||
.get<SuccessListResponse<RoleDTO>>(ROOT_API_ENDPOINT, { params })
|
.get<SuccessListResponse<RoleDTO>>('/role', { params })
|
||||||
.then((data) => data.data);
|
.then((data) => data.data);
|
||||||
};
|
};
|
||||||
|
|
||||||
getDetailed = async (id: string) => {
|
getDetailed = async (id: string) => {
|
||||||
return axiosClient.get<RoleDTO>(`${ROOT_API_ENDPOINT}/${id}`).then((data) => data.data);
|
return axiosClient.get<RoleDTO>(`/role/${id}`).then((data) => data.data);
|
||||||
};
|
};
|
||||||
|
|
||||||
create = async (payload: { name: string; permissions: string[] }) => {
|
create = async (payload: { name: string; permissions: string[] }) => {
|
||||||
return axiosClient
|
return axiosClient.post<SuccessMutationResponse>('/role', payload).then((data) => data.data);
|
||||||
.post<SuccessMutationResponse>(ROOT_API_ENDPOINT, payload)
|
|
||||||
.then((data) => data.data);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
update = async (payload: { id: number } & Record<string, unknown>) => {
|
update = async (payload: { id: number } & Record<string, unknown>) => {
|
||||||
return axiosClient
|
return axiosClient
|
||||||
.put<SuccessMutationResponse>(`${ROOT_API_ENDPOINT}/${payload.id}`, payload)
|
.put<SuccessMutationResponse>(`/role/${payload.id}`, payload)
|
||||||
.then((data) => data.data);
|
.then((data) => data.data);
|
||||||
};
|
};
|
||||||
|
|
||||||
delete = async (payload: { id: number }) => {
|
delete = async (payload: { id: number }) => {
|
||||||
return axiosClient
|
return axiosClient
|
||||||
.delete<SuccessMutationResponse>(`${ROOT_API_ENDPOINT}/${payload.id}`)
|
.delete<SuccessMutationResponse>(`/role/${payload.id}`)
|
||||||
.then((data) => data.data);
|
.then((data) => data.data);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { ListQueryParams, SuccessListResponse } from '#infrastructure/react
|
|||||||
import type { WebStudioSiteSettings } from '#core/WebStudio/config/siteSettings';
|
import type { WebStudioSiteSettings } from '#core/WebStudio/config/siteSettings';
|
||||||
import type { PageStatus } from '#core/WebStudio/entities/PageStatus';
|
import type { PageStatus } from '#core/WebStudio/entities/PageStatus';
|
||||||
import PagePayloadSerializer from './PagePayloadSerializer';
|
import PagePayloadSerializer from './PagePayloadSerializer';
|
||||||
import type { CreatePagePayloadDTO, PageDetailsDTO, PageListItemDTO, PreviewTokenDTO, PublishPageResultDTO, RevalidationDTO } from './WebStudioDTO';
|
import type { CreatePagePayloadDTO, PageDetailsDTO, PageListItemDTO, PreviewTokenDTO, PublishPageResultDTO, RevalidationDTO, SiteSettingsDTO } from './WebStudioDTO';
|
||||||
|
|
||||||
export interface PageListQueryParams extends ListQueryParams {
|
export interface PageListQueryParams extends ListQueryParams {
|
||||||
locale?: string;
|
locale?: string;
|
||||||
@@ -93,7 +93,7 @@ class WebStudioAPI {
|
|||||||
|
|
||||||
getSettings = async (locale: string) => {
|
getSettings = async (locale: string) => {
|
||||||
return axiosClient
|
return axiosClient
|
||||||
.get<{ data: WebStudioSiteSettings | null }>('/web-manager/settings', {
|
.get<{ data: SiteSettingsDTO | null }>('/web-manager/settings', {
|
||||||
params: { locale }
|
params: { locale }
|
||||||
})
|
})
|
||||||
.then((response) => response.data);
|
.then((response) => response.data);
|
||||||
@@ -101,7 +101,7 @@ class WebStudioAPI {
|
|||||||
|
|
||||||
updateSettings = async (settings: WebStudioSiteSettings) => {
|
updateSettings = async (settings: WebStudioSiteSettings) => {
|
||||||
return axiosClient
|
return axiosClient
|
||||||
.put<{ data: WebStudioSiteSettings }>('/web-manager/settings', settings)
|
.put<{ data: SiteSettingsDTO }>('/web-manager/settings', settings)
|
||||||
.then((response) => response.data);
|
.then((response) => response.data);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,26 @@
|
|||||||
import type { IsoDateString } from '@crux-kit/types';
|
import type { IsoDateString } from '@crux-kit/types';
|
||||||
|
import type { NavigationLink } from '#core/WebStudio/entities/field/NavigationLink';
|
||||||
|
|
||||||
|
export interface SiteSettingsMenuDTO {
|
||||||
|
title?: string;
|
||||||
|
items: NavigationLink[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SiteSettingsDTO {
|
||||||
|
locale: string;
|
||||||
|
header: {
|
||||||
|
menus: SiteSettingsMenuDTO[];
|
||||||
|
ctaItems: NavigationLink[];
|
||||||
|
};
|
||||||
|
footer: {
|
||||||
|
copyright?: string;
|
||||||
|
menus: SiteSettingsMenuDTO[];
|
||||||
|
};
|
||||||
|
legal: {
|
||||||
|
privacyPath?: string;
|
||||||
|
termsPath?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export interface PageListItemDTO {
|
export interface PageListItemDTO {
|
||||||
id: number;
|
id: number;
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import type { PageLanguage } from '#core/WebStudio/config/languages';
|
||||||
|
import type { WebStudioSettingsMenu, WebStudioSiteSettings } from '#core/WebStudio/config/siteSettings';
|
||||||
|
import type { SiteSettingsDTO, SiteSettingsMenuDTO } from '#core/WebStudio/data/api/WebStudioDTO';
|
||||||
|
|
||||||
|
class SiteSettingsMapper {
|
||||||
|
toEntity = (dto: SiteSettingsDTO): WebStudioSiteSettings => ({
|
||||||
|
locale: dto.locale as PageLanguage,
|
||||||
|
header: {
|
||||||
|
menus: dto.header.menus.map(this.toMenu),
|
||||||
|
ctaItems: dto.header.ctaItems
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
copyright: dto.footer.copyright ?? '',
|
||||||
|
menus: dto.footer.menus.map(this.toMenu)
|
||||||
|
},
|
||||||
|
legal: {
|
||||||
|
privacyPath: dto.legal.privacyPath ?? '',
|
||||||
|
termsPath: dto.legal.termsPath ?? ''
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
private toMenu = (menu: SiteSettingsMenuDTO): WebStudioSettingsMenu => ({
|
||||||
|
title: menu.title ?? '',
|
||||||
|
items: menu.items
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new SiteSettingsMapper();
|
||||||
@@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|||||||
import type { PageLanguage } from '#core/WebStudio/config/languages';
|
import type { PageLanguage } from '#core/WebStudio/config/languages';
|
||||||
import type { WebStudioSiteSettings } from '#core/WebStudio/config/siteSettings';
|
import type { WebStudioSiteSettings } from '#core/WebStudio/config/siteSettings';
|
||||||
import WebStudioAPI from '#core/WebStudio/data/api/WebStudioAPI';
|
import WebStudioAPI from '#core/WebStudio/data/api/WebStudioAPI';
|
||||||
|
import SiteSettingsMapper from '#core/WebStudio/data/mappers/SiteSettingsMapper';
|
||||||
import { pageSettingsKeyBuilder } from './keys';
|
import { pageSettingsKeyBuilder } from './keys';
|
||||||
|
|
||||||
export const useGetPageSettings = (locale: PageLanguage) => {
|
export const useGetPageSettings = (locale: PageLanguage) => {
|
||||||
@@ -9,7 +10,7 @@ export const useGetPageSettings = (locale: PageLanguage) => {
|
|||||||
queryKey: pageSettingsKeyBuilder.store({ locale }),
|
queryKey: pageSettingsKeyBuilder.store({ locale }),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const response = await WebStudioAPI.getSettings(locale);
|
const response = await WebStudioAPI.getSettings(locale);
|
||||||
return response.data;
|
return response.data ? SiteSettingsMapper.toEntity(response.data) : null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -20,7 +21,7 @@ export const useUpdatePageSettings = () => {
|
|||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (settings: WebStudioSiteSettings) => {
|
mutationFn: async (settings: WebStudioSiteSettings) => {
|
||||||
const response = await WebStudioAPI.updateSettings(settings);
|
const response = await WebStudioAPI.updateSettings(settings);
|
||||||
return response.data;
|
return SiteSettingsMapper.toEntity(response.data);
|
||||||
},
|
},
|
||||||
onSuccess: (settings) => {
|
onSuccess: (settings) => {
|
||||||
queryClient.setQueryData(pageSettingsKeyBuilder.store({ locale: settings.locale }), settings);
|
queryClient.setQueryData(pageSettingsKeyBuilder.store({ locale: settings.locale }), settings);
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { LoaderCircle, Save } from 'lucide-react';
|
import { LoaderCircle, Save } from 'lucide-react';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { FieldErrors } from 'react-hook-form';
|
import type { FieldPath } from 'react-hook-form';
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { handleQueryError } from '#infrastructure/reactQuery';
|
import { extractValidationErrors, getErrorMessage, handleQueryError, isValidationError, type ErrorResponse } from '#infrastructure/reactQuery';
|
||||||
import { PermissionPageGate } from '#core/Permission';
|
import { PermissionPageGate } from '#core/Permission';
|
||||||
import { DEFAULT_WEB_STUDIO_SITE_SETTINGS_RECORD } from '#core/WebStudio/config/siteSettings';
|
import { DEFAULT_WEB_STUDIO_SITE_SETTINGS_RECORD } from '#core/WebStudio/config/siteSettings';
|
||||||
import { useGetPageSettings, useUpdatePageSettings } from '#core/WebStudio/data/queries/settings';
|
import { useGetPageSettings, useUpdatePageSettings } from '#core/WebStudio/data/queries/settings';
|
||||||
import { useWebStudioQueryParams } from '#core/WebStudio/services/useWebStudioQueryParams';
|
import { useWebStudioQueryParams } from '#core/WebStudio/services/useWebStudioQueryParams';
|
||||||
import { WEB_SETTINGS_FORM_ID, WebSettingsForm, type FormValues } from '#core/WebStudio/view/forms/WebSettingsForm';
|
import { WEB_SETTINGS_FORM_ID, WebSettingsForm, type FormValues, type WebSettingsInvalidHandler, type WebSettingsSubmitHandler } from '#core/WebStudio/view/forms/WebSettingsForm';
|
||||||
import { Button } from '#shared/ui/Button';
|
import { Button } from '#shared/ui/Button';
|
||||||
import { Skeleton } from '#shared/ui/Skeleton';
|
import { Skeleton } from '#shared/ui/Skeleton';
|
||||||
import { Tabs, TabsList, TabsTrigger } from '#shared/ui/Tabs';
|
import { Tabs, TabsList, TabsTrigger } from '#shared/ui/Tabs';
|
||||||
@@ -26,18 +26,42 @@ const PageSettings: React.FC = () => {
|
|||||||
const { data, isLoading } = useGetPageSettings(locale);
|
const { data, isLoading } = useGetPageSettings(locale);
|
||||||
const { mutate, isPending } = useUpdatePageSettings();
|
const { mutate, isPending } = useUpdatePageSettings();
|
||||||
|
|
||||||
const handleSubmit = (values: FormValues) => {
|
const handleSubmit: WebSettingsSubmitHandler = (values, form) => {
|
||||||
mutate(values, {
|
mutate(values, {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success(intl.formatMessage({ id: 'WEB_STUDIO.SETTINGS.SAVE_SUCCESS' }));
|
toast.success(intl.formatMessage({ id: 'WEB_STUDIO.SETTINGS.SAVE_SUCCESS' }));
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
handleQueryError(error);
|
if (!isValidationError(error as ErrorResponse)) {
|
||||||
|
handleQueryError(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const validationErrors = extractValidationErrors(error as ErrorResponse);
|
||||||
|
if (validationErrors.length === 0) {
|
||||||
|
toast.error(getErrorMessage(error));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
validationErrors.forEach((validationError) => {
|
||||||
|
form.setError(validationError.field as FieldPath<FormValues>, {
|
||||||
|
type: 'apiError',
|
||||||
|
message: validationError.message
|
||||||
|
});
|
||||||
|
});
|
||||||
|
toast.error(intl.formatMessage({ id: 'WEB_STUDIO.FORM.VALIDATION_ERROR' }));
|
||||||
|
|
||||||
|
const sectionWithError = SETTINGS_SECTIONS.find((candidate) =>
|
||||||
|
validationErrors.some((validationError) => validationError.field.startsWith(candidate))
|
||||||
|
);
|
||||||
|
if (sectionWithError && sectionWithError !== section) {
|
||||||
|
changeSection(sectionWithError);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleInvalid = (errors: FieldErrors<FormValues>) => {
|
const handleInvalid: WebSettingsInvalidHandler = (errors) => {
|
||||||
toast.error(intl.formatMessage({ id: 'WEB_STUDIO.FORM.VALIDATION_ERROR' }));
|
toast.error(intl.formatMessage({ id: 'WEB_STUDIO.FORM.VALIDATION_ERROR' }));
|
||||||
|
|
||||||
const sectionWithError = SETTINGS_SECTIONS.find((candidate) => errors[candidate]);
|
const sectionWithError = SETTINGS_SECTIONS.find((candidate) => errors[candidate]);
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import type { FieldErrors } from 'react-hook-form';
|
|
||||||
import { FormElement } from '#shared/forms/composed';
|
import { FormElement } from '#shared/forms/composed';
|
||||||
import { Form } from '#shared/ui/form';
|
import { Form } from '#shared/ui/form';
|
||||||
import type { FormValues } from './form';
|
import type { FormValues, WebSettingsInvalidHandler, WebSettingsSubmitHandler } from './form';
|
||||||
import { useWebSettingsForm, WEB_SETTINGS_FORM_ID } from './form';
|
import { useWebSettingsForm, WEB_SETTINGS_FORM_ID } from './form';
|
||||||
import FooterSettingsSection from './sections/FooterSettingsSection';
|
import FooterSettingsSection from './sections/FooterSettingsSection';
|
||||||
import HeaderSettingsSection from './sections/HeaderSettingsSection';
|
import HeaderSettingsSection from './sections/HeaderSettingsSection';
|
||||||
@@ -11,8 +10,8 @@ interface WebSettingsFormProps {
|
|||||||
initialData: FormValues;
|
initialData: FormValues;
|
||||||
section: string;
|
section: string;
|
||||||
isSubmitting?: boolean;
|
isSubmitting?: boolean;
|
||||||
onSubmit: (settings: FormValues) => void;
|
onSubmit: WebSettingsSubmitHandler;
|
||||||
onInvalid?: (errors: FieldErrors<FormValues>) => void;
|
onInvalid?: WebSettingsInvalidHandler;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function WebSettingsForm({
|
export default function WebSettingsForm({
|
||||||
@@ -26,7 +25,10 @@ export default function WebSettingsForm({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<FormElement id={WEB_SETTINGS_FORM_ID} onSubmit={form.handleSubmit(onSubmit, onInvalid)}>
|
<FormElement
|
||||||
|
id={WEB_SETTINGS_FORM_ID}
|
||||||
|
onSubmit={form.handleSubmit((values) => onSubmit(values, form), onInvalid)}
|
||||||
|
>
|
||||||
<fieldset
|
<fieldset
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
className="min-w-0 transition-opacity disabled:pointer-events-none disabled:opacity-70"
|
className="min-w-0 transition-opacity disabled:pointer-events-none disabled:opacity-70"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useForm as useReactHookForm } from 'react-hook-form';
|
import { useForm as useReactHookForm, type FieldErrors, type UseFormReturn } from 'react-hook-form';
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
import z from 'zod';
|
import z from 'zod';
|
||||||
import { createValidationSchema } from './validationSchema';
|
import { createValidationSchema } from './validationSchema';
|
||||||
@@ -8,6 +8,13 @@ export const WEB_SETTINGS_FORM_ID = 'web-studio-web-settings-form';
|
|||||||
|
|
||||||
export type FormValues = z.infer<ReturnType<typeof createValidationSchema>>;
|
export type FormValues = z.infer<ReturnType<typeof createValidationSchema>>;
|
||||||
|
|
||||||
|
export type WebSettingsSubmitHandler = (
|
||||||
|
settings: FormValues,
|
||||||
|
form: UseFormReturn<FormValues>
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
export type WebSettingsInvalidHandler = (errors: FieldErrors<FormValues>) => void;
|
||||||
|
|
||||||
export const useWebSettingsForm = (initialData: FormValues) => {
|
export const useWebSettingsForm = (initialData: FormValues) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
export { default as WebSettingsForm } from './Component';
|
export { default as WebSettingsForm } from './Component';
|
||||||
export { WEB_SETTINGS_FORM_ID } from './form';
|
export { WEB_SETTINGS_FORM_ID } from './form';
|
||||||
export type { FormValues } from './form';
|
export type { FormValues, WebSettingsInvalidHandler, WebSettingsSubmitHandler } from './form';
|
||||||
|
|||||||
@@ -16,8 +16,13 @@ export default class AdminsController {
|
|||||||
search = null,
|
search = null,
|
||||||
sortBy = 'id',
|
sortBy = 'id',
|
||||||
sortOrder = 'desc',
|
sortOrder = 'desc',
|
||||||
|
status = null,
|
||||||
} = request.all()
|
} = request.all()
|
||||||
const query = Admins.query()
|
const query = Admins.query()
|
||||||
|
if (status) {
|
||||||
|
const statuses = Array.isArray(status) ? status : [status]
|
||||||
|
query.whereIn('status', statuses)
|
||||||
|
}
|
||||||
if (search) {
|
if (search) {
|
||||||
const columns = Admins.searchable || (await searchableColumns('admins'))
|
const columns = Admins.searchable || (await searchableColumns('admins'))
|
||||||
addSearchFilter(query, columns, search)
|
addSearchFilter(query, columns, search)
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { HttpContext } from '@adonisjs/core/http'
|
import type { HttpContext } from '@adonisjs/core/http'
|
||||||
import listValidator from '#base_modules/FileManager/validator/list_validator'
|
import listValidator from '#base_modules/FileManager/validator/list_validator'
|
||||||
import uploadFileValidator from '#base_modules/FileManager/validator/upload_file_validator'
|
import uploadFileValidator from '#base_modules/FileManager/validator/upload_file_validator'
|
||||||
import createDirectoryValidator from '#base_modules/FileManager/validator/create_directory_validator'
|
import createDirectoryValidator from '#base_modules/FileManager/validator/create_directory_validator'
|
||||||
import renameEntryValidator from '#base_modules/FileManager/validator/rename_entry_validator'
|
import renameEntryValidator from '#base_modules/FileManager/validator/rename_entry_validator'
|
||||||
import duplicateEntriesValidator from '#base_modules/FileManager/validator/duplicate_entries_validator'
|
import duplicateEntriesValidator from '#base_modules/FileManager/validator/duplicate_entries_validator'
|
||||||
import FileMangerPolicy from '#base_modules/FileManager/policy/file_manager_policy'
|
import FileMangerPolicy from '#base_modules/FileManager/policy/file_manager_policy'
|
||||||
import { Entry, FileEntry, FolderEntry } from '#base_modules/FileManager/types/entries'
|
import type { Entry, FileEntry, FolderEntry } from '#base_modules/FileManager/types/entries'
|
||||||
import getValidatedPathFromRequest from '#base_modules/FileManager/helpers/get_validated_path_from_request'
|
import getValidatedPathFromRequest from '#base_modules/FileManager/helpers/get_validated_path_from_request'
|
||||||
import FileManagerService from '#base_modules/FileManager/services/file_manager_service'
|
import FileManagerService from '#base_modules/FileManager/services/file_manager_service'
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Entry } from '#base_modules/FileManager/types/entries'
|
import type { Entry } from '#base_modules/FileManager/types/entries'
|
||||||
|
|
||||||
export type EntryFilter = 'media' | 'archives' | 'documents' | 'all'
|
export type EntryFilter = 'media' | 'archives' | 'documents' | 'all'
|
||||||
export type EntrySortBy = 'name' | 'size' | 'type' | 'date'
|
export type EntrySortBy = 'name' | 'size' | 'type' | 'date'
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { MultipartFile } from '@adonisjs/core/bodyparser'
|
import type { MultipartFile } from '@adonisjs/core/bodyparser'
|
||||||
import { Entry, FileEntry, FolderEntry } from '#base_modules/FileManager/types/entries'
|
import type { Entry, FileEntry, FolderEntry } from '#base_modules/FileManager/types/entries'
|
||||||
import {
|
import {
|
||||||
default as EntryListService,
|
default as EntryListService,
|
||||||
EntryListParams,
|
type EntryListParams,
|
||||||
EntryListResult,
|
type EntryListResult,
|
||||||
} from '#base_modules/FileManager/services/entry_list_service'
|
} from '#base_modules/FileManager/services/entry_list_service'
|
||||||
import createStorageService from '#base_modules/FileManager/services/storage_service/create_storage_service'
|
import createStorageService from '#base_modules/FileManager/services/storage_service/create_storage_service'
|
||||||
import {
|
import type {
|
||||||
FileStream,
|
FileStream,
|
||||||
StorageService,
|
StorageService,
|
||||||
} from '#base_modules/FileManager/services/storage_service/storage_service_interface'
|
} from '#base_modules/FileManager/services/storage_service/storage_service_interface'
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { FileEntry } from '#base_modules/FileManager/types/entries'
|
import type { FileEntry } from '#base_modules/FileManager/types/entries'
|
||||||
|
|
||||||
const PREVIEWABLE_TEXT_EXTENSIONS = ['txt', 'md', 'log', 'json', 'csv']
|
const PREVIEWABLE_TEXT_EXTENSIONS = ['txt', 'md', 'log', 'json', 'csv']
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
import env from '#start/env'
|
import env from '#start/env'
|
||||||
import { StorageService } from './storage_service_interface.js'
|
import type { StorageService } from './storage_service_interface.js'
|
||||||
import { FSStorageService } from './fs_storage_service.js'
|
import { FSStorageService } from './fs_storage_service.js'
|
||||||
import { S3StorageService } from './s3_storage_service.js'
|
import { S3StorageService } from './s3_storage_service.js'
|
||||||
|
|
||||||
|
|||||||
+42
-18
@@ -4,9 +4,9 @@ import {
|
|||||||
DirNotFoundException,
|
DirNotFoundException,
|
||||||
FileNotFoundException,
|
FileNotFoundException,
|
||||||
} from '#exceptions/file_manager_exceptions'
|
} from '#exceptions/file_manager_exceptions'
|
||||||
import { Entry, FileEntry, FolderEntry } from '#base_modules/FileManager/types/entries'
|
|
||||||
import { StorageService } from './storage_service_interface.js'
|
|
||||||
import PathService from '#base_modules/FileManager/services/path_service'
|
import PathService from '#base_modules/FileManager/services/path_service'
|
||||||
|
import type { Entry, FileEntry, FolderEntry } from '#base_modules/FileManager/types/entries'
|
||||||
|
import type { StorageService } from '#base_modules/FileManager/services/storage_service/storage_service_interface'
|
||||||
import FSDirectoryMarkerService from '#base_modules/FileManager/services/storage_service/fs_directory_marker_service'
|
import FSDirectoryMarkerService from '#base_modules/FileManager/services/storage_service/fs_directory_marker_service'
|
||||||
|
|
||||||
export class FSStorageService implements StorageService {
|
export class FSStorageService implements StorageService {
|
||||||
@@ -14,21 +14,41 @@ export class FSStorageService implements StorageService {
|
|||||||
private pathService = new PathService()
|
private pathService = new PathService()
|
||||||
private directoryMarkerService = new FSDirectoryMarkerService()
|
private directoryMarkerService = new FSDirectoryMarkerService()
|
||||||
|
|
||||||
|
private directoryPrefix(path: string): string {
|
||||||
|
return path ? `${path.replace(/\/$/, '')}/` : path
|
||||||
|
}
|
||||||
|
|
||||||
|
private async listDirectoryFiles(path: string): Promise<{ key: string; relative: string }[]> {
|
||||||
|
const prefix = this.directoryPrefix(path)
|
||||||
|
const files: { key: string; relative: string }[] = []
|
||||||
|
|
||||||
|
let paginationToken: string | undefined
|
||||||
|
|
||||||
|
do {
|
||||||
|
const response = await this.disk.listAll(prefix, { recursive: true, paginationToken })
|
||||||
|
|
||||||
|
for (const item of response.objects) {
|
||||||
|
if (!item.isFile || !item.key.startsWith(prefix)) continue
|
||||||
|
|
||||||
|
files.push({ key: item.key, relative: item.key.slice(prefix.length) })
|
||||||
|
}
|
||||||
|
|
||||||
|
paginationToken = response.paginationToken
|
||||||
|
} while (paginationToken)
|
||||||
|
|
||||||
|
return files
|
||||||
|
}
|
||||||
|
|
||||||
private async moveDirectory(oldPath: string, newPath: string) {
|
private async moveDirectory(oldPath: string, newPath: string) {
|
||||||
const response = await this.disk.listAll(oldPath, { recursive: true })
|
await this.disk.put(this.directoryMarkerService.markerPath(newPath), '')
|
||||||
|
|
||||||
for (const item of response.objects) {
|
for (const file of await this.listDirectoryFiles(oldPath)) {
|
||||||
if (!item.isFile) continue
|
const target = this.pathService.join(newPath, file.relative)
|
||||||
|
const dir = this.pathService.dirname(target)
|
||||||
const relative = item.key.slice(oldPath.length).replace(/^\/+/, '')
|
|
||||||
|
|
||||||
const target = `${newPath}/${relative}`
|
|
||||||
|
|
||||||
const dir = target.split('/').slice(0, -1).join('/')
|
|
||||||
|
|
||||||
if (dir) await this.disk.put(this.directoryMarkerService.markerPath(dir), '')
|
if (dir) await this.disk.put(this.directoryMarkerService.markerPath(dir), '')
|
||||||
|
|
||||||
await this.disk.move(item.key, target)
|
await this.disk.move(file.key, target)
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.disk.deleteAll(oldPath)
|
await this.disk.deleteAll(oldPath)
|
||||||
@@ -43,7 +63,15 @@ export class FSStorageService implements StorageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async directoryExists(path: string): Promise<boolean> {
|
private async directoryExists(path: string): Promise<boolean> {
|
||||||
return this.disk.exists(this.directoryMarkerService.markerPath(path))
|
if (await this.disk.exists(this.directoryMarkerService.markerPath(path))) return true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.disk.listAll(this.directoryPrefix(path))
|
||||||
|
|
||||||
|
return Array.from(response.objects).length > 0
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getFileEntryFromStoragePath(
|
private async getFileEntryFromStoragePath(
|
||||||
@@ -226,11 +254,7 @@ export class FSStorageService implements StorageService {
|
|||||||
|
|
||||||
if (exists) return this.getFileEntry(path)
|
if (exists) return this.getFileEntry(path)
|
||||||
|
|
||||||
const response = await this.disk.listAll(source)
|
return this.getDirectoryEntry(path)
|
||||||
|
|
||||||
if (Array.from(response.objects).length) return this.getDirectoryEntry(path)
|
|
||||||
|
|
||||||
throw new Error('Entry not found')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public writeFile: StorageService['writeFile'] = async (path, file) => {
|
public writeFile: StorageService['writeFile'] = async (path, file) => {
|
||||||
|
|||||||
+41
-20
@@ -5,9 +5,9 @@ import {
|
|||||||
DirNotFoundException,
|
DirNotFoundException,
|
||||||
FileNotFoundException,
|
FileNotFoundException,
|
||||||
} from '#exceptions/file_manager_exceptions'
|
} from '#exceptions/file_manager_exceptions'
|
||||||
import { Entry, FileEntry, FolderEntry } from '#base_modules/FileManager/types/entries'
|
|
||||||
import { StorageService } from './storage_service_interface.js'
|
|
||||||
import PathService from '#base_modules/FileManager/services/path_service'
|
import PathService from '#base_modules/FileManager/services/path_service'
|
||||||
|
import type { Entry, FileEntry, FolderEntry } from '#base_modules/FileManager/types/entries'
|
||||||
|
import type { StorageService } from '#base_modules/FileManager/services/storage_service/storage_service_interface'
|
||||||
import FSDirectoryMarkerService from '#base_modules/FileManager/services/storage_service/fs_directory_marker_service'
|
import FSDirectoryMarkerService from '#base_modules/FileManager/services/storage_service/fs_directory_marker_service'
|
||||||
|
|
||||||
export class S3StorageService implements StorageService {
|
export class S3StorageService implements StorageService {
|
||||||
@@ -19,23 +19,38 @@ export class S3StorageService implements StorageService {
|
|||||||
return path ? `${path.replace(/\/$/, '')}/` : path
|
return path ? `${path.replace(/\/$/, '')}/` : path
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async listDirectoryFiles(path: string): Promise<{ key: string; relative: string }[]> {
|
||||||
|
const prefix = this.directoryPrefix(path)
|
||||||
|
const files: { key: string; relative: string }[] = []
|
||||||
|
|
||||||
|
let paginationToken: string | undefined
|
||||||
|
|
||||||
|
do {
|
||||||
|
const response = await this.disk.listAll(prefix, { recursive: true, paginationToken })
|
||||||
|
|
||||||
|
for (const item of response.objects) {
|
||||||
|
if (!item.isFile || !item.key.startsWith(prefix)) continue
|
||||||
|
|
||||||
|
files.push({ key: item.key, relative: item.key.slice(prefix.length) })
|
||||||
|
}
|
||||||
|
|
||||||
|
paginationToken = response.paginationToken
|
||||||
|
} while (paginationToken)
|
||||||
|
|
||||||
|
return files
|
||||||
|
}
|
||||||
|
|
||||||
private async moveDirectory(oldPath: string, newPath: string) {
|
private async moveDirectory(oldPath: string, newPath: string) {
|
||||||
const oldPrefix = this.directoryPrefix(oldPath)
|
await this.disk.put(this.directoryMarkerService.markerPath(newPath), '')
|
||||||
const response = await this.disk.listAll(oldPrefix, { recursive: true })
|
|
||||||
|
|
||||||
for (const item of response.objects) {
|
for (const file of await this.listDirectoryFiles(oldPath)) {
|
||||||
if (!item.isFile) continue
|
const target = this.pathService.join(newPath, file.relative)
|
||||||
|
const dir = this.pathService.dirname(target)
|
||||||
const relative = item.key.slice(oldPrefix.length).replace(/^\/+/, '')
|
|
||||||
const target = this.pathService.join(newPath, relative)
|
|
||||||
const dir = target.split('/').slice(0, -1).join('/')
|
|
||||||
|
|
||||||
if (dir) await this.disk.put(this.directoryMarkerService.markerPath(dir), '')
|
if (dir) await this.disk.put(this.directoryMarkerService.markerPath(dir), '')
|
||||||
|
|
||||||
await this.disk.move(item.key, target)
|
await this.disk.move(file.key, target)
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.disk.deleteAll(oldPrefix)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public entryExists: StorageService['entryExists'] = async (path) => {
|
public entryExists: StorageService['entryExists'] = async (path) => {
|
||||||
@@ -47,7 +62,15 @@ export class S3StorageService implements StorageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async directoryExists(path: string): Promise<boolean> {
|
private async directoryExists(path: string): Promise<boolean> {
|
||||||
return this.disk.exists(this.directoryMarkerService.markerPath(path))
|
if (await this.disk.exists(this.directoryMarkerService.markerPath(path))) return true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.disk.listAll(this.directoryPrefix(path))
|
||||||
|
|
||||||
|
return Array.from(response.objects).length > 0
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getFileEntryFromStoragePath(
|
private async getFileEntryFromStoragePath(
|
||||||
@@ -157,7 +180,9 @@ export class S3StorageService implements StorageService {
|
|||||||
if (entry.type === 'file') {
|
if (entry.type === 'file') {
|
||||||
await this.disk.delete(storagePath)
|
await this.disk.delete(storagePath)
|
||||||
} else {
|
} else {
|
||||||
await this.disk.deleteAll(this.directoryPrefix(storagePath))
|
for (const file of await this.listDirectoryFiles(storagePath)) {
|
||||||
|
await this.disk.delete(file.key)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { path }
|
return { path }
|
||||||
@@ -238,11 +263,7 @@ export class S3StorageService implements StorageService {
|
|||||||
|
|
||||||
if (exists) return this.getFileEntry(path)
|
if (exists) return this.getFileEntry(path)
|
||||||
|
|
||||||
const response = await this.disk.listAll(source)
|
return this.getDirectoryEntry(path)
|
||||||
|
|
||||||
if (Array.from(response.objects).length) return this.getDirectoryEntry(path)
|
|
||||||
|
|
||||||
throw new Error('Entry not found')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public writeFile: StorageService['writeFile'] = async (path, file) => {
|
public writeFile: StorageService['writeFile'] = async (path, file) => {
|
||||||
|
|||||||
+3
-3
@@ -1,6 +1,6 @@
|
|||||||
import { Readable } from 'node:stream'
|
import type { Readable } from 'node:stream'
|
||||||
import { MultipartFile } from '@adonisjs/core/bodyparser'
|
import type { MultipartFile } from '@adonisjs/core/bodyparser'
|
||||||
import { Entry, FileEntry } from '#base_modules/FileManager/types/entries'
|
import type { Entry, FileEntry } from '#base_modules/FileManager/types/entries'
|
||||||
|
|
||||||
export interface FileStream {
|
export interface FileStream {
|
||||||
stream: Readable
|
stream: Readable
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ export type ComponentContent =
|
|||||||
export type BlockData = ComponentContent['data']
|
export type BlockData = ComponentContent['data']
|
||||||
|
|
||||||
export interface SiteMenu {
|
export interface SiteMenu {
|
||||||
title: string
|
title?: string
|
||||||
items: NavigationLink[]
|
items: NavigationLink[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,11 +146,11 @@ export interface SiteSettingsContent {
|
|||||||
ctaItems: NavigationLink[]
|
ctaItems: NavigationLink[]
|
||||||
}
|
}
|
||||||
footer: {
|
footer: {
|
||||||
copyright: string
|
copyright?: string
|
||||||
menus: SiteMenu[]
|
menus: SiteMenu[]
|
||||||
}
|
}
|
||||||
legal: {
|
legal: {
|
||||||
privacyPath: string
|
privacyPath?: string
|
||||||
termsPath: string
|
termsPath?: string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ const componentContentSchema = () =>
|
|||||||
|
|
||||||
const siteMenuSchema = () =>
|
const siteMenuSchema = () =>
|
||||||
vine.object({
|
vine.object({
|
||||||
title: vine.string(),
|
title: vine.string().optional(),
|
||||||
items: vine.array(navigationLinkSchema()),
|
items: vine.array(navigationLinkSchema()),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -175,12 +175,12 @@ const siteSettingsContentSchema = () =>
|
|||||||
ctaItems: vine.array(navigationLinkSchema()),
|
ctaItems: vine.array(navigationLinkSchema()),
|
||||||
}),
|
}),
|
||||||
footer: vine.object({
|
footer: vine.object({
|
||||||
copyright: vine.string(),
|
copyright: vine.string().optional(),
|
||||||
menus: vine.array(siteMenuSchema()),
|
menus: vine.array(siteMenuSchema()),
|
||||||
}),
|
}),
|
||||||
legal: vine.object({
|
legal: vine.object({
|
||||||
privacyPath: vine.string(),
|
privacyPath: vine.string().optional(),
|
||||||
termsPath: vine.string(),
|
termsPath: vine.string().optional(),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { test } from '@japa/runner'
|
||||||
|
import drive from '@adonisjs/drive/services/main'
|
||||||
|
import { FSStorageService } from '#base_modules/FileManager/services/storage_service/fs_storage_service'
|
||||||
|
|
||||||
|
const disk = drive.use('fs')
|
||||||
|
const storage = new FSStorageService()
|
||||||
|
|
||||||
|
async function cleanup() {
|
||||||
|
for (const dir of ['aa', 'aab', 'cc', 'ghostdir', 'ghostdir2']) {
|
||||||
|
await disk.deleteAll(dir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test.group('FSStorageService directories', (group) => {
|
||||||
|
group.each.setup(() => cleanup())
|
||||||
|
group.each.teardown(() => cleanup())
|
||||||
|
|
||||||
|
test('finds a directory that has no .keep marker', async ({ assert }) => {
|
||||||
|
await disk.put('ghostdir/a.txt', 'hello')
|
||||||
|
|
||||||
|
assert.isTrue(await storage.entryExists('ghostdir'))
|
||||||
|
assert.deepInclude(await storage.getEntry('ghostdir'), { type: 'folder', name: 'ghostdir' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('moves a marker-less directory with its contents', async ({ assert }) => {
|
||||||
|
await disk.put('ghostdir/nested/a.txt', 'hello')
|
||||||
|
|
||||||
|
await storage.moveEntry('ghostdir', 'ghostdir2')
|
||||||
|
|
||||||
|
assert.isFalse(await disk.exists('ghostdir/nested/a.txt'))
|
||||||
|
assert.equal(await disk.get('ghostdir2/nested/a.txt'), 'hello')
|
||||||
|
assert.deepInclude(await storage.getEntry('ghostdir2'), { type: 'folder' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('does not touch a sibling directory sharing the same name prefix', async ({ assert }) => {
|
||||||
|
await disk.put('aa/a.txt', 'from aa')
|
||||||
|
await disk.put('aab/a.txt', 'from aab')
|
||||||
|
|
||||||
|
await storage.moveEntry('aa', 'cc')
|
||||||
|
|
||||||
|
assert.equal(await disk.get('cc/a.txt'), 'from aa')
|
||||||
|
assert.equal(await disk.get('aab/a.txt'), 'from aab')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('deletes a directory without touching a prefix sibling', async ({ assert }) => {
|
||||||
|
await disk.put('aa/a.txt', 'from aa')
|
||||||
|
await disk.put('aab/a.txt', 'from aab')
|
||||||
|
|
||||||
|
await storage.deleteEntry('aa')
|
||||||
|
|
||||||
|
assert.isFalse(await disk.exists('aa/a.txt'))
|
||||||
|
assert.equal(await disk.get('aab/a.txt'), 'from aab')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -131,6 +131,7 @@ services:
|
|||||||
- backend
|
- backend
|
||||||
volumes:
|
volumes:
|
||||||
- ./apps/web:/app/apps/web:cached
|
- ./apps/web:/app/apps/web:cached
|
||||||
|
- /app/apps/web/node_modules
|
||||||
- /app/apps/web/.next
|
- /app/apps/web/.next
|
||||||
environment:
|
environment:
|
||||||
HOSTNAME: 0.0.0.0
|
HOSTNAME: 0.0.0.0
|
||||||
|
|||||||
Reference in New Issue
Block a user