Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 21 additions & 10 deletions src/entities/project/api/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@ export class ProjectHttp {
});
}

static getProject(teamId: string, id: string, token?: string, signal?: AbortSignal) {
static getProject(teamId: string, slug: string, token?: string, signal?: AbortSignal) {
return api<TProject.ProjectDetailResponse>({
url: `/teams/${teamId}/projects/${id}`,
url: `/teams/${teamId}/projects/${slug}`,
method: 'GET',
params: token ? { token } : undefined,
contracts: {
Expand All @@ -38,9 +38,9 @@ export class ProjectHttp {
});
}

static updateProject(teamId: string, id: string, data: TProject.UpdateProjectBody) {
static updateProject(teamId: string, slug: string, data: TProject.UpdateProjectBody) {
return api<TProject.ActionResponse>({
url: `/teams/${teamId}/projects/${id}`,
url: `/teams/${teamId}/projects/${slug}`,
method: 'PATCH',
data,
contracts: {
Expand All @@ -50,29 +50,29 @@ export class ProjectHttp {
});
}

static removeProject(teamId: string, id: string) {
static removeProject(teamId: string, slug: string) {
return api<TProject.ActionResponse>({
url: `/teams/${teamId}/projects/${id}`,
url: `/teams/${teamId}/projects/${slug}`,
method: 'DELETE',
contracts: {
response: SProject.ActionResponse,
},
});
}

static archiveProject(teamId: string, id: string) {
static archiveProject(teamId: string, slug: string) {
return api<TProject.ActionResponse>({
url: `/teams/${teamId}/projects/${id}/archive`,
url: `/teams/${teamId}/projects/${slug}/archive`,
method: 'POST',
contracts: {
response: SProject.ActionResponse,
},
});
}

static createShareToken(teamId: string, id: string, data: TProject.CreateShareTokenBody = {}) {
static createShareToken(teamId: string, slug: string, data: TProject.CreateShareTokenBody = {}) {
return api<TProject.CreateShareTokenResponse>({
url: `/teams/${teamId}/projects/${id}/share`,
url: `/teams/${teamId}/projects/${slug}/share`,
method: 'POST',
data,
contracts: {
Expand All @@ -81,4 +81,15 @@ export class ProjectHttp {
},
});
}

static checkSlug(teamId: string, slug: string, signal?: AbortSignal) {
return api<TProject.CheckSlugResponse>({
url: `/teams/${teamId}/projects/check-slug?q=${slug}`,
method: 'GET',
contracts: {
response: SProject.CheckSlugResponse,
},
signal,
});
}
}
15 changes: 12 additions & 3 deletions src/entities/project/api/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,20 @@ export class ProjectQueries {
});
}

static getProject(teamId: string, id: string, token?: string) {
static getProject(teamId: string, slug: string, token?: string) {
return queryOptions({
queryKey: [...projectFabricKeys.detail(teamId, id), token ?? null],
queryFn: async ({ signal }) => ProjectHttp.getProject(teamId, id, token, signal),
queryKey: [...projectFabricKeys.detail(teamId, slug), token ?? null],
queryFn: async ({ signal }) => ProjectHttp.getProject(teamId, slug, token, signal),
staleTime: 60_000,
});
}

static checkSlug(teamId: string, slug: string) {
return queryOptions({
queryKey: projectFabricKeys.checkSlug(teamId, slug),
queryFn: async ({ signal }) => ProjectHttp.checkSlug(teamId, slug, signal),
gcTime: 5000,
staleTime: 5000,
});
}
}
3 changes: 3 additions & 0 deletions src/entities/project/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,6 @@ export { PROJECT_ICONS } from './config/icons';
export { PROJECT_COLORS } from './config/colors';
export { projectIconCodeToEmoji } from './lib/emoji';
export { buildProjectShareUrl } from './lib/share-url';
export { validateProjectSlugAsync } from './lib/validate-project-slug';
export { SlugField } from './ui/SlugField';
export { useCheckSlug } from './lib/useCheckSlug';
4 changes: 2 additions & 2 deletions src/entities/project/lib/share-url.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
export function buildProjectShareUrl(projectId: string, token: string) {
export function buildProjectShareUrl(slug: string, token: string) {
const origin = typeof window !== 'undefined' ? window.location.origin : '';
const url = new URL(`/projects/${projectId}`, origin || 'http://localhost');
const url = new URL(`/projects/${slug}`, origin || 'http://localhost');

url.searchParams.set('token', token);

Expand Down
90 changes: 90 additions & 0 deletions src/entities/project/lib/useCheckSlug.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { useQueryClient } from '@tanstack/react-query';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import type { FieldErrors } from 'react-hook-form';
import { debounce } from 'shared/lib/utils';
import { ProjectQueries } from '../api/queries';
import { MAX_SLUG_LENGTH, MIN_SLUG_LENGTH, projectFabricKeys } from '../model/const';

const DEBOUNCE_MS = 400;
const SLUG_UNAVAILABLE_MESSAGE = 'Этот адрес уже занят';

export type CheckSlugErrors = FieldErrors<{ slug: string }>;

function prepareResponse(available: boolean, message?: string | null): CheckSlugErrors {
if (available) {
return {};
}

return {
slug: {
type: 'validate',
message: message ?? SLUG_UNAVAILABLE_MESSAGE,
},
};
}

export function useCheckSlug(defaultValue: string, teamId: string) {
const currentSlug = useRef(defaultValue);
const pendingResolve = useRef<((errors: CheckSlugErrors) => void) | null>(null);
const queryClient = useQueryClient();

const resolvePending = useCallback((errors: CheckSlugErrors) => {
pendingResolve.current?.(errors);
pendingResolve.current = null;
}, []);

const debouncedCheckSlug = useMemo(() => {
// eslint-disable-next-line react-hooks/refs -- refs read only in async-callback debounce
return debounce(async (value: string) => {
try {
const data = await queryClient.fetchQuery(ProjectQueries.checkSlug(teamId!, value));

// when the server response arrives but is already outdated
if (value !== currentSlug.current) {
return;
}

resolvePending(prepareResponse(data.available, data.reason));
} catch {
if (value === currentSlug.current) {
resolvePending({});
}
}
}, DEBOUNCE_MS);
}, [queryClient, resolvePending, teamId]);

const cancel = useCallback(() => {
debouncedCheckSlug.cancelDebouncedCallback();
queryClient.cancelQueries({ queryKey: projectFabricKeys.checkSlug() });
resolvePending({});
}, [debouncedCheckSlug, queryClient, resolvePending]);

useEffect(() => {
return () => {
cancel();
queryClient.removeQueries({ queryKey: projectFabricKeys.checkSlug() });
};
}, [cancel, queryClient]);

return useCallback(
(value: string): Promise<CheckSlugErrors> =>
new Promise((resolve) => {
const isValid =
defaultValue !== value &&
value.length >= MIN_SLUG_LENGTH &&
value.length <= MAX_SLUG_LENGTH;

cancel();
currentSlug.current = value;

if (!isValid) {
resolve({});
return;
}

pendingResolve.current = resolve;
debouncedCheckSlug.debouncedCallback(value);
}),
[cancel, debouncedCheckSlug, defaultValue]
);
}
42 changes: 42 additions & 0 deletions src/entities/project/lib/useSlugFieldStatus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import { ProjectQueries } from '../api/queries';
import { MAX_SLUG_LENGTH, MIN_SLUG_LENGTH } from '../model/const';

type SlugFieldStatusValue = 'pending' | 'success' | 'error';

interface SlugFieldStatusState {
isDirty: boolean;
slug: string;
}

export function useSlugFieldStatus({
isDirty,
slug,
teamId,
}: SlugFieldStatusState & { teamId: string }): SlugFieldStatusValue | undefined {
const { data, isPending } = useQuery({
...ProjectQueries.checkSlug(teamId!, slug),
enabled: false,
});

return useMemo(() => {
if (!isDirty || slug.length < MIN_SLUG_LENGTH || slug.length > MAX_SLUG_LENGTH) {
return undefined;
}

if (isPending) {
return 'pending';
}

if (data?.available === false) {
return 'error';
}

if (data?.available === true) {
return 'success';
}

return undefined;
}, [data?.available, isDirty, isPending, slug]);
}
21 changes: 21 additions & 0 deletions src/entities/project/lib/validate-project-slug.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { FieldValues, ResolverOptions } from 'react-hook-form';
import type { CheckSlugErrors } from './useCheckSlug';

type SlugFormValues = { slug?: string };

function shouldValidateSlugAsync(names: readonly string[] | undefined): boolean {
return !names?.length || names.includes('slug');
}

export function validateProjectSlugAsync<T extends FieldValues & SlugFormValues>(
checkSlug: (value: string) => Promise<CheckSlugErrors>,
values: T,
_context: unknown,
options: ResolverOptions<T>
): Promise<CheckSlugErrors> {
if (!shouldValidateSlugAsync(options.names as readonly string[] | undefined)) {
return Promise.resolve({});
}

return checkSlug(values.slug ?? '');
}
11 changes: 10 additions & 1 deletion src/entities/project/model/const.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
import { createEntityKeys } from 'shared/lib/utils';

export const PROJECT_STATUSES = ['active', 'archived', 'template', 'deleted'] as const;
export const PROJECT_VISIBILITIES = ['public', 'private'] as const;
export const LAYOUTS = ['kanban', 'list', 'calendar', 'gantt'] as const;
export const MEMBER_ROLE = ['owner', 'admin', 'member', 'viewer'] as const;
export const MIN_SLUG_LENGTH = 1 as const;
export const MAX_SLUG_LENGTH = 100 as const;

export const projectFabricKeys = createEntityKeys('project', {
list: (teamId: string) => ['teams', teamId, 'projects'],
detail: (teamId: string, id: string) => ['teams', teamId, 'projects', id],
detail: (teamId: string, slug: string) => ['teams', teamId, 'projects', slug],
checkSlug: (teamId?: string, slug?: string) =>
['teams', teamId, 'projects', 'check-slug', slug].filter(Boolean),
});
Loading
Loading